diff --git a/blog/src/_data/media.json b/blog/src/_data/media.json index b664ee1005..56dcbdb915 100644 --- a/blog/src/_data/media.json +++ b/blog/src/_data/media.json @@ -5169,5 +5169,60 @@ "status": "ready" }, "inline": {} + }, + "claude-duet-alternatives": { + "title": "Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist", + "category": "comparisons", + "hero": { + "file": "assets/media/claude-duet-alternatives/hero.png", + "alt": "Title card on white. A pill reading Alternatives, the title Claude Duet alternatives, the line Proactive agents with no waitlist and a Checked 22 Oct 2026 line. Beside them a queue of numbered tickets under a Waitlist sign, and four lanes that light up one by one, each tagged Open now.", + "prompt": "Title card: the hero scene in assets/media/claude-duet-alternatives/motion.js, shot at 1600 by 900.", + "status": "ready" + }, + "inline": {} + }, + "claude-duet-vs-munder-difflin": { + "title": "Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer", + "category": "comparisons", + "hero": { + "file": "assets/media/claude-duet-vs-munder-difflin/hero.png", + "alt": "Title card on white. A pill reading Versus, the title Claude Duet vs Munder Difflin, the line One agent in the cloud, or a team on your computer and a Checked 22 Oct 2026 line. On the left a cloud holds one card; on the right four small terminal windows type, with a yellow vs chip between them.", + "prompt": "Title card: the hero scene in assets/media/claude-duet-vs-munder-difflin/motion.js, shot at 1600 by 900.", + "status": "ready" + }, + "inline": {} + }, + "build-your-own-claude-duet": { + "title": "Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase", + "category": "guides", + "hero": { + "file": "assets/media/build-your-own-claude-duet/hero.png", + "alt": "Title card on white. A pill reading Guide, the title Build your own Claude Duet, the line A free morning brief for your codebase and a Checked 22 Oct 2026 line. A yellow sun rises behind a terminal that prints Morning brief, Pull requests, Checks and Issues, beside a calendar tile reading 08:00.", + "prompt": "Title card: the hero scene in assets/media/build-your-own-claude-duet/motion.js, shot at 1600 by 900.", + "status": "ready" + }, + "inline": {} + }, + "claude-duet-for-developers": { + "title": "Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't", + "category": "comparisons", + "hero": { + "file": "assets/media/claude-duet-for-developers/hero.png", + "alt": "Title card on white. A pill reading Developers, the title Is Claude Duet good for developers?, the line What it does with code, and what it doesn't and a Checked 22 Oct 2026 line. A balance scale holds an inbox tray on one pan and a code block on the other and settles level.", + "prompt": "Title card: the hero scene in assets/media/claude-duet-for-developers/motion.js, shot at 1600 by 900.", + "status": "ready" + }, + "inline": {} + }, + "claude-duet-waitlist": { + "title": "Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile", + "category": "guides", + "hero": { + "file": "assets/media/claude-duet-waitlist/hero.png", + "alt": "Title card on white. A pill reading Access, the title Claude Duet waitlist, the line Who gets in first, and what to use meanwhile and a Checked 22 Oct 2026 line. Envelopes move along a rail; one lifts out and opens to a card reading You're in.", + "prompt": "Title card: the hero scene in assets/media/claude-duet-waitlist/motion.js, shot at 1600 by 900.", + "status": "ready" + }, + "inline": {} } } diff --git a/blog/src/assets/media/build-your-own-claude-duet/brief.png b/blog/src/assets/media/build-your-own-claude-duet/brief.png new file mode 100644 index 0000000000..0d4401060e Binary files /dev/null and b/blog/src/assets/media/build-your-own-claude-duet/brief.png differ diff --git a/blog/src/assets/media/build-your-own-claude-duet/hero.png b/blog/src/assets/media/build-your-own-claude-duet/hero.png new file mode 100644 index 0000000000..318c07ac0f Binary files /dev/null and b/blog/src/assets/media/build-your-own-claude-duet/hero.png differ diff --git a/blog/src/assets/media/build-your-own-claude-duet/motion.css b/blog/src/assets/media/build-your-own-claude-duet/motion.css new file mode 100644 index 0000000000..28b25c3416 --- /dev/null +++ b/blog/src/assets/media/build-your-own-claude-duet/motion.css @@ -0,0 +1,13 @@ +/* Bright coded scenes for "Build your own Claude Duet". Pairs with motion.js. */ +.prose figure.mg { margin: 42px 0; } +.prose figure.mg img, .mg .mg-svg { display: block; width: 100%; height: auto; border: 1px solid #1A1320; border-radius: 18px; background: #fff; } +.mg figcaption { font: 500 13px/1.5 "Space Grotesk", system-ui, sans-serif; color: #6B5878; padding-top: 10px; text-align: center; } +.mg .mg-svg { touch-action: manipulation; -webkit-tap-highlight-color: transparent; } +.mg .mg-svg text { -webkit-user-select: none; user-select: none; pointer-events: none; } +.mg .mg-hit { cursor: pointer; outline: none; } +.mg [role="slider"] { cursor: grab; touch-action: none; } +.mg .mg-ring { stroke-opacity: 0; transition: stroke-opacity 0.15s; } +.mg .mg-hit:focus-visible .mg-ring { stroke-opacity: 1; } +.mg [role="slider"]:focus-visible circle:nth-child(2) { stroke-width: 5; } +@media (hover: hover) { .mg .mg-hit:hover .mg-ring { stroke-opacity: 0.5; } } +@media (prefers-reduced-motion: reduce) { .mg .mg-ring { transition: none; } } diff --git a/blog/src/assets/media/build-your-own-claude-duet/motion.js b/blog/src/assets/media/build-your-own-claude-duet/motion.js new file mode 100644 index 0000000000..01dfca00ee --- /dev/null +++ b/blog/src/assets/media/build-your-own-claude-duet/motion.js @@ -0,0 +1,701 @@ +/* Bright coded scenes for "Build your own Claude Duet". Vanilla JS, no library. +
holds a still image; this script swaps in a live SVG. + Plays only in view, honours reduced motion, "?mgstill" freezes each scene on its still frame. */ +(() => { + const NS = "http://www.w3.org/2000/svg"; + const INK = "#1A1320", YEL = "#FFCA54", BLUE = "#6C8EF5", LIL = "#B69CFF", MINT = "#7FD8AE", SKY = "#9AD8FF", SOFT = "#F1ECF9", MUTE = "#7A6A88"; + const LIL_SOFT = "#E6DCFF", GREY = "#CAC3D2"; + const SANS = '"Space Grotesk", system-ui, sans-serif', MONO = '"JetBrains Mono", ui-monospace, monospace'; + const el = (t, a = {}, p) => { const n = document.createElementNS(NS, t); for (const k in a) n.setAttribute(k, a[k]); if (p) p.appendChild(n); return n; }; + const txt = (p, x, y, s, a = {}) => { const n = el("text", Object.assign({ x, y, "font-family": SANS, "font-size": 24, "font-weight": 600, fill: INK }, a), p); n.textContent = s; return n; }; + const mono = (p, x, y, s, a = {}) => txt(p, x, y, s, Object.assign({ "font-family": MONO, "font-weight": 500 }, a)); + const fit = (n, max) => { const w = n.getComputedTextLength(); if (w > max) n.setAttribute("font-size", (+n.getAttribute("font-size") * max) / w); return n; }; + const cl = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v)); + const seg = (t, a, b) => cl((t - a) / (b - a)); + const oc = (x) => 1 - Math.pow(1 - x, 3); + /* back out with an adjustable overshoot: c 1.7 is a lively arrival, c 0.8 a soft one */ + const bk = (x, c = 1.70158) => 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); + const ob = (x) => bk(x); + const io = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); + const dip = (k) => (k > 0 && k < 1 ? Math.sin(Math.PI * k) : 0); + const squash = (k) => (k > 0 && k < 1 ? Math.sin(k * Math.PI * 2) * (1 - k) * 0.14 : 0); + /* idle breath: 0 at rest, one smooth swell per period P, always at rest when t = end */ + const breathe = (t, end, P) => (1 - Math.cos((2 * Math.PI * (t - end)) / P)) / 2; + /* a cursor blink that is on at t = end */ + const blink = (t, end, P = 0.9) => ((((t - end) / P) % 1) + 1) % 1 < 0.55; + const tf = (n, x, y, s = 1, r = 0) => n.setAttribute("transform", `translate(${x} ${y}) rotate(${r}) scale(${s})`); + const hex = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + const mix = (a, b, k) => { const A = hex(a), B = hex(b); return "#" + A.map((v, i) => Math.round(v + (B[i] - v) * cl(k)).toString(16).padStart(2, "0")).join(""); }; + const rise = (n, k, d = 16) => { n.setAttribute("transform", `translate(0 ${d * (1 - oc(k))})`); n.setAttribute("opacity", seg(k, 0, 0.4)); }; + /* show the first k characters of s, k from a time window */ + const type = (n, s, t, a, b) => { const k = Math.floor(seg(t, a, b) * s.length + 1e-6); n.textContent = s.slice(0, k); return k; }; + const qb = (A, C, B, p) => { const u = 1 - p; return [u * u * A[0] + 2 * u * p * C[0] + p * p * B[0], u * u * A[1] + 2 * u * p * C[1] + p * p * B[1]]; }; + const qa = (A, C, B, p) => { const u = 1 - p, dx = 2 * u * (C[0] - A[0]) + 2 * p * (B[0] - C[0]), dy = 2 * u * (C[1] - A[1]) + 2 * p * (B[1] - C[1]); return (Math.atan2(dy, dx) * 180) / Math.PI; }; + const button = (g, label, fn) => { + g.setAttribute("role", "button"); g.setAttribute("tabindex", "0"); g.setAttribute("aria-label", label); g.classList.add("mg-hit"); + g.addEventListener("click", fn); + g.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); } }); + }; + const hint = (svg, s, x = 772, y = 50) => txt(svg, x, y, s, { "text-anchor": "end", "font-size": 16, fill: MUTE, "font-weight": 500 }); + /* a pill button with a dashed focus ring, centred on its origin */ + const mkBtn = (p, label, fs = 18, fill = YEL) => { + const g = el("g", {}, p), body = el("g", {}, g); + const r = el("rect", { y: -21, height: 42, rx: 21, fill, stroke: INK, "stroke-width": 2.5 }, body); + const t = txt(body, 0, 6.5, label, { "text-anchor": "middle", "font-size": fs, "font-weight": 700 }); + const w = Math.ceil(t.getComputedTextLength()) + 44; + r.setAttribute("x", -w / 2); r.setAttribute("width", w); + el("rect", { x: -w / 2 - 6, y: -27, width: w + 12, height: 54, rx: 27, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, body); + return { g, body, r, w }; + }; + /* an envelope centred on its origin */ + const mkEnv = (p, fill = YEL) => { + const g = el("g", { opacity: 0 }, p), inner = el("g", {}, g); + el("rect", { x: -20, y: -14, width: 40, height: 28, rx: 4, fill, stroke: INK, "stroke-width": 2.5 }, inner); + el("path", { d: "M-20 -13 L0 3 L20 -13", stroke: INK, "stroke-width": 2.5, fill: "none", "stroke-linejoin": "round" }, inner); + return { g, inner }; + }; + /* a small terminal window body: white box, coloured title band, three dots; returns dot nodes */ + const termFrame = (p, x0, y0, w, h, band, r = 12, bh = 30) => { + el("rect", { x: x0, y: y0, width: w, height: h, rx: r, fill: "#fff" }, p); + const head = el("path", { d: `M${x0} ${y0 + bh} V${y0 + r} A${r} ${r} 0 0 1 ${x0 + r} ${y0} H${x0 + w - r} A${r} ${r} 0 0 1 ${x0 + w} ${y0 + r} V${y0 + bh} Z`, fill: band }, p); + el("path", { d: `M${x0} ${y0 + bh} H${x0 + w}`, stroke: INK, "stroke-width": 2.5 }, p); + const dots = [0, 1, 2].map((i) => { const d = el("g", {}, p); el("circle", { r: 4, fill: "#fff", stroke: INK, "stroke-width": 2 }, d); tf(d, x0 + 18 + i * 13, y0 + bh / 2); return { d, x: x0 + 18 + i * 13, y: y0 + bh / 2 }; }); + el("rect", { x: x0, y: y0, width: w, height: h, rx: r, fill: "none", stroke: INK, "stroke-width": 3 }, p); + return { head, dots }; + }; + + const SC = {}; + + /* ---- title card, 16 by 9: a sun rises behind a terminal that prints the brief ---- */ + SC.hero = { + w: 960, h: 540, dur: 10, still: 10, + build(svg) { + const END = 10, SX = 750, SY = 222; + const defs = el("defs", {}, svg); + const pill = el("g", {}, svg); + const pr = el("rect", { y: -20, height: 40, rx: 20, fill: YEL, stroke: INK, "stroke-width": 2.5 }, pill); + const pt = txt(pill, 0, 7, "Guide", { "text-anchor": "middle", "font-size": 19, "font-weight": 700 }); + const pw = Math.ceil(pt.getComputedTextLength()) + 40; + pr.setAttribute("x", -pw / 2); pr.setAttribute("width", pw); + const l1g = el("g", {}, svg), l2g = el("g", {}, svg); + const l1 = txt(l1g, 60, 194, "Build your own", { "font-size": 66, "font-weight": 700 }); + const l2 = txt(l2g, 60, 268, "Claude Duet", { "font-size": 66, "font-weight": 700 }); + const TW = 520, tmax = Math.max(l1.getComputedTextLength(), l2.getComputedTextLength()); + if (tmax > TW) [l1, l2].forEach((n) => n.setAttribute("font-size", (66 * TW) / tmax)); + const UL = Math.round(l2.getComputedTextLength() * 0.6); + const under = el("path", { d: `M66 292 H${66 + UL}`, stroke: YEL, "stroke-width": 14, "stroke-linecap": "round", fill: "none" }, svg); + const s1g = el("g", {}, svg), s2g = el("g", {}, svg); + const s1 = txt(s1g, 63, 344, "A free morning brief", { "font-size": 27, "font-weight": 500, fill: MUTE }); + const s2 = txt(s2g, 63, 379, "for your codebase", { "font-size": 27, "font-weight": 500, fill: MUTE }); + const smax = Math.max(s1.getComputedTextLength(), s2.getComputedTextLength()); + if (smax > 380) [s1, s2].forEach((n) => n.setAttribute("font-size", (27 * 380) / smax)); + const chkG = el("g", {}, svg); + txt(chkG, 64, 470, "Checked 22 Oct 2026", { "font-size": 18, "font-weight": 500, fill: MUTE }); + + /* sky, clipped at the horizon so the sun climbs out of the ground */ + const cp = el("clipPath", { id: "mg-byo-sky" }, defs); + el("rect", { x: 0, y: 0, width: 960, height: 451 }, cp); + const sky = el("g", { "clip-path": "url(#mg-byo-sky)" }, svg), sun = el("g", {}, sky); + const glow = el("circle", { r: 104, fill: YEL, opacity: 0 }, sun); + const rays = Array.from({ length: 12 }, (_, i) => ({ p: el("path", { stroke: INK, "stroke-width": 4, "stroke-linecap": "round", fill: "none" }, sun), a: (i * Math.PI) / 6 - Math.PI / 2, i })); + el("circle", { r: 90, fill: YEL, stroke: INK, "stroke-width": 3 }, sun); + el("path", { d: "M-60 -34 A70 70 0 0 1 -22 -66", stroke: "#fff", "stroke-width": 8, "stroke-linecap": "round", fill: "none", opacity: 0.85 }, sun); + const RAY_ORDER = [0, 1, 11, 2, 10, 3, 9, 4, 8, 5, 7, 6]; + + /* horizon */ + const hz = el("g", {}, svg); + const hz1 = el("path", { d: "M466 452 H934", stroke: SOFT, "stroke-width": 16, "stroke-linecap": "round", fill: "none" }, hz); + const hz2 = el("path", { d: "M466 452 H934", stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round", fill: "none" }, hz); + + /* terminal */ + const T = el("g", {}, svg); + const tm = termFrame(T, -160, -105, 320, 210, SKY, 14, 32); + const LINES = [["Morning brief", 3.3, 4.05, null], ["Pull requests", 4.35, 5.0, BLUE], ["Checks", 5.25, 5.6, MINT], ["Issues", 5.9, 6.25, LIL]]; + el("path", { d: "M-138 -44 L-129 -37 L-138 -30", stroke: INK, "stroke-width": 3, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, T); + const lines = LINES.map(([s, a, b, c], i) => { + const y = i === 0 ? -30 : -30 + 36 + (i - 1) * 34; + const x = i === 0 ? -116 : -92; + const n = mono(T, x, y, "", i === 0 ? { "font-size": 20, "font-weight": 700 } : { "font-size": 19 }); + n.textContent = s; + const cw = n.getComputedTextLength() / s.length; + n.textContent = ""; + let dot = null; + if (c) { dot = el("g", {}, T); el("rect", { x: -6, y: -6, width: 12, height: 12, rx: 3, fill: c, stroke: INK, "stroke-width": 2 }, dot); } + return { n, s, a, b, x, y, cw, dot }; + }); + const cur = el("rect", { width: 10, height: 20, rx: 1.5, fill: INK }, T); + + /* calendar tile */ + const cal = el("g", {}, svg), calIn = el("g", {}, cal); + const burst = el("rect", { x: -44, y: -44, width: 88, height: 88, rx: 12, fill: "none", stroke: YEL, "stroke-width": 5, opacity: 0 }, cal); + el("rect", { x: -44, y: -44, width: 88, height: 88, rx: 12, fill: "#fff" }, calIn); + el("path", { d: "M-44 -16 V-32 A12 12 0 0 1 -32 -44 H32 A12 12 0 0 1 44 -32 V-16 Z", fill: LIL }, calIn); + el("path", { d: "M-44 -16 H44", stroke: INK, "stroke-width": 2.5 }, calIn); + el("rect", { x: -44, y: -44, width: 88, height: 88, rx: 12, fill: "none", stroke: INK, "stroke-width": 3 }, calIn); + [-20, 20].forEach((x) => el("rect", { x: x - 4, y: -53, width: 8, height: 18, rx: 4, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, calIn)); + fit(mono(calIn, 0, 22, "08:00", { "text-anchor": "middle", "font-size": 23, "font-weight": 700 }), 74); + + return (t) => { + const a = ob(seg(t, 0.15, 0.6)); + tf(pill, 64 + pw / 2, 82, 0.6 + 0.4 * a); pill.setAttribute("opacity", seg(t, 0.15, 0.3)); + [l1g, l2g].forEach((g, i) => rise(g, seg(t, 0.3 + i * 0.1, 0.9 + i * 0.1), 28)); + const ul = oc(seg(t, 0.95, 1.5)); under.setAttribute("stroke-dasharray", `${UL} ${UL + 20}`); under.setAttribute("stroke-dashoffset", UL * (1 - ul)); under.setAttribute("opacity", ul > 0 ? 1 : 0); + [s1g, s2g].forEach((g, i) => rise(g, seg(t, 0.62 + i * 0.09, 1.2 + i * 0.09), 16)); + rise(chkG, seg(t, 0.95, 1.4), 10); + + const hk = oc(seg(t, 0.5, 1.3)); + [hz1, hz2].forEach((n) => { n.setAttribute("stroke-dasharray", "470 480"); n.setAttribute("stroke-dashoffset", 470 * (1 - hk)); }); + hz.setAttribute("opacity", hk > 0 ? 1 : 0); + + const settle = seg(t, 6.6, 7.2), b = breathe(t, END, 2.8) * settle; + const sy = SY + 338 * (1 - bk(seg(t, 0.8, 3.0), 0.9)); + tf(sun, SX, sy, 1 + 0.015 * b); + const gk = seg(t, 2.6, 3.4); + glow.setAttribute("r", 100 + 8 * oc(gk) + 14 * b); + glow.setAttribute("opacity", 0.22 * oc(gk) + 0.16 * b); + rays.forEach((R) => { + const k = bk(seg(t, 2.35 + RAY_ORDER.indexOf(R.i) * 0.06, 2.75 + RAY_ORDER.indexOf(R.i) * 0.06), 2.2); + const r0 = 108, r1 = r0 + (20 + 4 * b) * k, c = Math.cos(R.a), s = Math.sin(R.a); + R.p.setAttribute("d", `M${(c * r0).toFixed(2)} ${(s * r0).toFixed(2)} L${(c * r1).toFixed(2)} ${(s * r1).toFixed(2)}`); + R.p.setAttribute("opacity", k > 0.02 ? 1 : 0); + }); + + const tk = seg(t, 1.2, 1.75); + tf(T, SX, 335 + 22 * (1 - oc(tk)), 0.78 + 0.22 * ob(tk)); + T.setAttribute("opacity", seg(tk, 0, 0.3)); + tm.dots.forEach((D, j) => tf(D.d, D.x, D.y, ob(seg(t, 1.55 + j * 0.08, 1.9 + j * 0.08)))); + let cx = -116, cy = -30, typing = false; + lines.forEach((L) => { + const k = type(L.n, L.s, t, L.a, L.b); + if (L.dot) { const dk = seg(t, L.a - 0.2, L.a + 0.1); tf(L.dot, -108, L.y - 6.5, ob(dk)); L.dot.setAttribute("opacity", dk > 0 ? 1 : 0); } + if (t >= L.a - 0.2) { cx = L.x + k * L.cw; cy = L.y; } + if (t >= L.a && t < L.b + 0.05) typing = true; + }); + cur.setAttribute("x", cx + 3); cur.setAttribute("y", cy - 16); + cur.setAttribute("opacity", tk > 0.5 && (typing || blink(t, END)) ? 1 : 0); + + const ck = seg(t, 1.7, 2.3), hp = dip(seg(t, 2.85, 3.25)); + tf(cal, 516, 404 - 64 * (1 - oc(ck)) - 12 * hp, 1 + 0.07 * hp, -6 - 16 * (1 - bk(ck, 2.4))); + cal.setAttribute("opacity", seg(ck, 0, 0.25)); + const bu = seg(t, 2.9, 3.6); + burst.setAttribute("transform", `scale(${1 + 0.45 * bu})`); burst.setAttribute("opacity", bu > 0 && bu < 1 ? 1 - bu : 0); + }; + }, + }; + + /* ---- 1. a schedule form fills in, saves, and at 08:00 sends its prompt to the brief agent ---- */ + SC.schedule = { + dur: 14, still: 14, + build(svg, api) { + const END = 14, RP = 1.45; + const hintT = hint(svg, "Tap Replay"); + + /* the form card */ + const card = el("g", {}, svg); + el("rect", { x: 40, y: 76, width: 452, height: 488, rx: 22, fill: "#fff", stroke: INK, "stroke-width": 3 }, card); + const head = el("g", {}, svg); + txt(head, 66, 122, "NEW SCHEDULE", { "font-size": 19, "font-weight": 700, "letter-spacing": 2.5 }); + const ic = el("g", {}, head); + el("rect", { x: 438, y: 100, width: 28, height: 26, rx: 6, fill: YEL, stroke: INK, "stroke-width": 2.5 }, ic); + el("path", { d: "M438 109 H466 M446 96 V103 M458 96 V103", stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round" }, ic); + + /* wrap the prompt to the box */ + const PROMPT = "List open pull requests, failing checks and new issues. Write me a short brief."; + const probe = txt(svg, 0, -50, "", { "font-size": 18, "font-weight": 500 }); + const wrap = (s, max) => { const out = []; let line = ""; s.split(" ").forEach((w) => { const tryL = line ? line + " " + w : w; probe.textContent = tryL; if (probe.getComputedTextLength() > max && line) { out.push(line); line = w; } else line = tryL; }); out.push(line); return out; }; + const PL = wrap(PROMPT, 336); + probe.remove(); + + const F = [["LABEL", "Morning brief", 150, 1.5, 2.3], ["GOES TO", "brief agent", 232, 2.5, 3.2], ["WHEN", "Every day, 08:00", 314, 3.4, 4.3], ["PROMPT", null, 396, 4.5, 6.9]]; + const fields = F.map(([lab, val, fy, a, b], i) => { + const g = el("g", {}, svg); + txt(g, 66, fy, lab, { "font-size": 13, "font-weight": 700, fill: MUTE, "letter-spacing": 1.6 }); + const h = val ? 44 : PL.length * 26 + 22; + const halo = el("rect", { x: 62, y: fy + 6, width: 408, height: h + 8, rx: 14, fill: "none", stroke: SKY, "stroke-width": 5, opacity: 0 }, g); + const box = el("rect", { x: 66, y: fy + 10, width: 400, height: h, rx: 11, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, g); + if (lab === "GOES TO") el("path", { d: `M440 ${fy + 28} L447 ${fy + 35} L454 ${fy + 28}`, stroke: INK, "stroke-width": 2.5, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, g); + const rows = (val ? [val] : PL).map((s, j) => ({ s, n: txt(g, 80, val ? fy + 39 : fy + 37 + j * 26, "", { "font-size": val ? 19 : 18, "font-weight": 500 }) })); + let off = 0; rows.forEach((r) => { r.o = off; off += r.s.length + 1; }); + const total = off - 1; + const caret = el("rect", { width: 2.5, height: 22, fill: INK, opacity: 0 }, g); + return { g, halo, box, rows, total, caret, a, b, i }; + }); + + const save = el("g", {}, svg), saveB = el("g", {}, save); + const sr = el("rect", { x: 0, y: -21, height: 42, rx: 21, fill: YEL, stroke: INK, "stroke-width": 2.5 }, saveB); + const st = txt(saveB, 22, 6.5, "Save", { "font-size": 18, "font-weight": 700 }); + const sw = Math.ceil(st.getComputedTextLength()) + 44; sr.setAttribute("width", sw); + const tag = el("g", {}, svg); + const tr = el("rect", { x: 0, y: -16, height: 32, rx: 16, fill: MINT, stroke: INK, "stroke-width": 2.5 }, tag); + el("path", { d: "M13 0 L18 5 L27 -5", stroke: INK, "stroke-width": 2.5, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, tag); + const tt = txt(tag, 35, 6, "saved", { "font-size": 17, "font-weight": 700 }); + const tw = Math.ceil(tt.getComputedTextLength()) + 50; tr.setAttribute("width", tw); + + /* the clock */ + const CK = [660, 176], R = 62; + const clock = el("g", {}, svg); + const ring = el("circle", { r: R, fill: "none", stroke: YEL, "stroke-width": 6, opacity: 0 }, clock); + el("circle", { r: R, fill: "#fff", stroke: INK, "stroke-width": 3 }, clock); + for (let i = 0; i < 12; i++) { const an = (i * Math.PI) / 6, r1 = i % 3 ? 50 : 45; el("path", { d: `M${(Math.cos(an) * r1).toFixed(2)} ${(Math.sin(an) * r1).toFixed(2)} L${(Math.cos(an) * 54).toFixed(2)} ${(Math.sin(an) * 54).toFixed(2)}`, stroke: INK, "stroke-width": i % 3 ? 2 : 3.5, "stroke-linecap": "round" }, clock); } + const hourH = el("path", { d: "M0 5 V-30", stroke: INK, "stroke-width": 6, "stroke-linecap": "round" }, clock); + const minH = el("path", { d: "M0 6 V-44", stroke: BLUE, "stroke-width": 4.5, "stroke-linecap": "round" }, clock); + el("circle", { r: 7, fill: YEL, stroke: INK, "stroke-width": 2.5 }, clock); + const read = el("g", {}, svg); + el("rect", { x: -54, y: -20, width: 108, height: 40, rx: 20, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, read); + fit(mono(read, 0, 7.5, "08:00", { "text-anchor": "middle", "font-size": 21, "font-weight": 700 }), 84); + + /* the brief agent's terminal */ + const TC = [660, 494]; + const tile = el("g", {}, svg); + const tGlow = el("rect", { x: -116, y: -70, width: 232, height: 140, rx: 18, fill: "none", stroke: YEL, "stroke-width": 7, opacity: 0 }, tile); + const tBurst = el("rect", { x: -110, y: -64, width: 220, height: 128, rx: 14, fill: "none", stroke: YEL, "stroke-width": 5, opacity: 0 }, tile); + const tfm = termFrame(tile, -110, -64, 220, 128, SOFT, 14, 30); + el("path", { d: "M-88 -12 L-80 -5 L-88 2", stroke: INK, "stroke-width": 2.8, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, tile); + fit(mono(tile, -70, 2, "brief agent", { "font-size": 19, "font-weight": 700 }), 170); + const bars = [[-70, 22, 118], [-70, 40, 76]].map(([x, y, w]) => ({ n: el("rect", { x, y: y - 4, width: 0, height: 8, rx: 4, fill: GREY }, tile), w })); + const tCur = el("rect", { x: 14, y: 30, width: 9, height: 17, rx: 1.5, fill: INK, opacity: 0 }, tile); + + /* the envelope's path, a dotted trail and the envelope */ + const A0 = [462, 414], A = [530, 414], C = [640, 330], B = [742, 440]; + const ecp = el("clipPath", { id: "mg-byo-out" }, el("defs", {}, svg)); + el("rect", { x: 494, y: 0, width: 400, height: 600 }, ecp); + const trail = Array.from({ length: 16 }, (_, i) => { const p = 0.03 + i * 0.054, [x, y] = qb(A, C, B, p); return { n: el("circle", { cx: x, cy: y, r: 3, fill: GREY, opacity: 0 }, svg), p }; }); + const envWrap = el("g", {}, svg), env = mkEnv(envWrap); + + const btn = mkBtn(svg, "Replay"); + hintT.setAttribute("x", 772 - btn.w - 18); hintT.setAttribute("y", 56); + let press = -99; + button(btn.g, "Replay the schedule", () => { press = api.now(); api.seek(RP); }); + + return (t, now) => { + rise(card, seg(t, 0.15, 0.7), 24); + rise(head, seg(t, 0.35, 0.8), 12); + fields.forEach((f) => rise(f.g, seg(t, 0.5 + f.i * 0.1, 0.95 + f.i * 0.1), 14)); + const sk = seg(t, 0.95, 1.4); + tf(save, 66, 522, 1); save.setAttribute("opacity", seg(sk, 0, 0.4)); + saveB.setAttribute("transform", `translate(${sw / 2} 0) scale(${0.7 + 0.3 * ob(sk)}) translate(${-sw / 2} 0)`); + hintT.setAttribute("opacity", seg(t, 1.0, 1.4)); + + /* typing, one field at a time, with a focus halo on the live field */ + fields.forEach((f) => { + const k = Math.floor(seg(t, f.a, f.b) * f.total + 1e-6); + let lastRow = f.rows[0]; + f.rows.forEach((r) => { const n = cl(k - r.o, 0, r.s.length); r.n.textContent = r.s.slice(0, n); if (k >= r.o) lastRow = r; }); + const live = t >= f.a - 0.15 && t < f.b + 0.25; + const hk = live ? Math.min(seg(t, f.a - 0.15, f.a + 0.05), 1 - seg(t, f.b + 0.05, f.b + 0.25)) : 0; + f.halo.setAttribute("opacity", hk); + f.box.setAttribute("stroke", mix(INK, BLUE, hk)); + const typingNow = t >= f.a && t < f.b; + f.caret.setAttribute("x", +lastRow.n.getAttribute("x") + lastRow.n.getComputedTextLength() + 2); + f.caret.setAttribute("y", +lastRow.n.getAttribute("y") - 17); + f.caret.setAttribute("opacity", live && (typingNow || blink(t, f.a, 0.7)) ? 1 : 0); + }); + + const pd = dip(seg(t, 7.2, 7.5)); + saveB.firstChild.setAttribute("fill", mix(YEL, "#FFE3A0", pd)); + if (pd > 0) saveB.setAttribute("transform", `translate(${sw / 2} ${3 * pd}) scale(${1 - 0.07 * pd}) translate(${-sw / 2} 0)`); + const gk = seg(t, 7.45, 7.9); + tf(tag, 66 + sw + 16, 522, 0.4 + 0.6 * ob(gk)); + tag.setAttribute("opacity", seg(gk, 0, 0.3)); + + /* clock: in, then sweeps from seven to eight */ + const ce = seg(t, 0.8, 1.35); + const sweep = io(seg(t, 7.95, 9.7)), wig = seg(t, 9.7, 10.25), w = wig > 0 && wig < 1 ? 7 * Math.sin(wig * Math.PI * 3) * (1 - wig) : 0; + tf(clock, CK[0], CK[1], 0.5 + 0.5 * ob(ce)); + clock.setAttribute("opacity", seg(ce, 0, 0.3)); + tf(minH, 0, 0, 1, 360 * sweep + w); + tf(hourH, 0, 0, 1, 210 + 30 * sweep + w * 0.3); + const pk = seg(t, 9.7, 10.4); + ring.setAttribute("r", R + 24 * pk); ring.setAttribute("opacity", pk > 0 && pk < 1 ? 1 - pk : 0); + const rk = seg(t, 9.75, 10.2); + tf(read, CK[0], 278, 0.5 + 0.5 * ob(rk)); read.setAttribute("opacity", seg(rk, 0, 0.3)); + + /* envelope: pops out of the prompt box, arcs over, docks on the tile */ + const settle = seg(t, 11.9, 12.4), bob = 3.5 * Math.sin((2 * Math.PI * (t - END)) / 2.1) * settle; + const e0 = seg(t, 9.8, 10.2), f = seg(t, 10.2, 11.0), p = io(f), land = seg(t, 11.0, 11.4); + if (f <= 0) envWrap.setAttribute("clip-path", "url(#mg-byo-out)"); else envWrap.removeAttribute("clip-path"); + if (t < 9.8) env.g.setAttribute("opacity", 0); + else if (f <= 0) { const m = bk(e0, 1.0); tf(env.g, A0[0] + (A[0] - A0[0]) * m, A[1], 1, -6 * oc(e0)); env.g.setAttribute("opacity", 1); } + else { + const [x, y] = qb(A, C, B, p), ang = qa(A, C, B, p); + const rot = f < 1 ? -6 + (ang * 0.25) * Math.sin(Math.PI * f) + 14 * p : 8; + tf(env.g, x, y + (f >= 1 ? bob : 0), f < 1 ? 1 + 0.22 * Math.sin(Math.PI * p) : 1, rot); + env.g.setAttribute("opacity", 1); + } + const q = squash(land); + env.inner.setAttribute("transform", `translate(0 14) scale(${1 + q} ${1 - q}) translate(0 -14)`); + trail.forEach((d) => d.n.setAttribute("opacity", f >= d.p ? 0.9 : 0)); + + /* tile: in early, lights up when the envelope lands */ + const te = seg(t, 0.95, 1.5), lk = seg(t, 11.0, 11.35), th = dip(seg(t, 11.0, 11.4)); + tf(tile, TC[0], TC[1] + 18 * (1 - oc(te)), (0.75 + 0.25 * ob(te)) * (1 + 0.04 * th)); + tile.setAttribute("opacity", seg(te, 0, 0.3)); + tfm.head.setAttribute("fill", mix(SOFT, YEL, lk)); + tfm.dots.forEach((D, j) => tf(D.d, D.x, D.y, ob(seg(t, 0.95 + j * 0.08, 1.25 + j * 0.08)))); + const tb = seg(t, 11.0, 11.7); + tBurst.setAttribute("transform", `scale(${1 + 0.2 * tb} ${1 + 0.35 * tb})`); tBurst.setAttribute("opacity", tb > 0 && tb < 1 ? 1 - tb : 0); + const gl = breathe(t, END, 2.1) * settle; + tGlow.setAttribute("opacity", lk * (0.3 + 0.35 * gl)); + bars.forEach((B2, j) => B2.n.setAttribute("width", B2.w * oc(seg(t, 11.2 + j * 0.14, 11.6 + j * 0.14)))); + tCur.setAttribute("x", -70 + 76 + 8); + tCur.setAttribute("y", 31); + tCur.setAttribute("opacity", t > 11.8 && blink(t, END) ? 1 : 0); + + const be = seg(t, 0.9, 1.35), bp = api.calm ? 0 : dip(seg(now, press, press + 0.3)); + tf(btn.g, 772 - btn.w / 2, 50 - 12 * (1 - ob(be))); btn.g.setAttribute("opacity", seg(be, 0, 0.4)); + btn.body.setAttribute("transform", `translate(0 ${3 * bp}) scale(${1 - 0.05 * bp})`); + }; + }, + }; + + /* ---- 2. three gh commands feed three sections of a morning brief ---- */ + SC.brief = { + dur: 12, still: 12, + build(svg, api) { + const END = 12, ALL = 8.7, AUTO = 9.0; + const hintT = hint(svg, "Tap a section"); + const defs = el("defs", {}, svg); + + /* ribbons sit under the terminal and the card */ + const ribL = el("g", {}, svg); + const CY = [206, 292, 378]; + const S = [ + ["gh pr list", "Pull requests:", "3 waiting for review", "Oldest waiting 2 days", BLUE, 2.0, 2.75], + ["gh run list", "Checks:", "1 failing on main", "Test job, last commit", YEL, 4.15, 4.95], + ["gh issue list", "Issues:", "2 new since yesterday", "1 labelled bug", LIL, 6.3, 7.2], + ]; + const ribs = S.map(([, , , , c]) => { + const g = el("g", {}, ribL); + const o = el("path", { stroke: INK, "stroke-width": 15, fill: "none", "stroke-linecap": "butt" }, g); + const m = el("path", { stroke: c, "stroke-width": 10, fill: "none", "stroke-linecap": "butt" }, g); + const f = el("path", { stroke: "#fff", "stroke-width": 3, fill: "none", "stroke-dasharray": "3 13", "stroke-linecap": "round", opacity: 0 }, g); + return { g, o, m, f }; + }); + + /* terminal */ + const term = el("g", {}, svg); + const tm = termFrame(term, 36, 96, 280, 382, SKY, 16, 34); + const cmds = S.map(([s, , , , , a, b], i) => { + const y = CY[i]; + const ps = mono(term, 56, y, "$", { "font-size": 19, fill: MUTE, opacity: 0 }); + const n = mono(term, 80, y, "", { "font-size": 19 }); + n.textContent = s; const cw = n.getComputedTextLength() / s.length; n.textContent = ""; + const out = [[80, 70 + i * 14], [80 + 78 + i * 14, 54 - i * 6]].map(([x, w]) => ({ n: el("rect", { x, y: y + 15, width: 0, height: 8, rx: 4, fill: GREY }, term), w })); + return { ps, n, s, cw, a, b, y, out }; + }); + const lastPs = mono(term, 56, 450, "$", { "font-size": 19, fill: MUTE, opacity: 0 }); + const tCur = el("rect", { width: 10, height: 20, rx: 1.5, fill: INK }, term); + + /* the brief card: a title panel, then two panels that unfold downwards */ + const card = el("g", {}, svg); + const X0 = 420, X1 = 764, Y0 = 96, P1 = 170, P2 = 324, Y1 = 478; + const p1 = el("path", { d: `M${X0} ${P1} V${Y0 + 18} A18 18 0 0 1 ${X0 + 18} ${Y0} H${X1 - 18} A18 18 0 0 1 ${X1} ${Y0 + 18} V${P1} Z`, fill: "#fff" }, card); + const flap = (y0, y1, last) => { + const g = el("g", {}, card), h = y1 - y0; + const d = `M${X0} ${y0} H${X1} V${y1 - 18} A18 18 0 0 1 ${X1 - 18} ${y1} H${X0 + 18} A18 18 0 0 1 ${X0} ${y1 - 18} Z`; + el("path", { d, fill: "#fff" }, g); + const shade = el("path", { d, fill: LIL_SOFT, opacity: 0 }, g); + const edge = el("path", { d, fill: "none", stroke: INK, "stroke-width": 2.5, opacity: 0 }, g); + return { g, shade, edge, y0, h }; + }; + const f2 = flap(P1, P2, false), f3 = flap(P2, Y1, true); + const crease = [P1, P2].map((y) => el("path", { d: `M${X0 + 10} ${y} H${X1 - 10}`, stroke: GREY, "stroke-width": 2, "stroke-dasharray": "5 6", opacity: 0 }, card)); + const outline = el("rect", { x: X0, y: Y0, width: X1 - X0, rx: 18, fill: "none", stroke: INK, "stroke-width": 3 }, card); + const ctitle = txt(card, 446, 143, "Morning brief", { "font-size": 25, "font-weight": 700 }); + const csun = el("g", {}, card); + for (let i = 0; i < 8; i++) { const an = (i * Math.PI) / 4; el("path", { d: `M${(Math.cos(an) * 13).toFixed(2)} ${(Math.sin(an) * 13).toFixed(2)} L${(Math.cos(an) * 18).toFixed(2)} ${(Math.sin(an) * 18).toFixed(2)}`, stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round" }, csun); } + el("circle", { r: 9, fill: YEL, stroke: INK, "stroke-width": 2.5 }, csun); + const cap = txt(svg, (X0 + X1) / 2, 524, "Example numbers", { "text-anchor": "middle", "font-size": 16, "font-weight": 500, fill: MUTE }); + + /* sections */ + const H0 = 76, HX = 32, GAP = 10, TOP = 184; + let pinned = -1, snap = [0, 0, 0], pickAt = -99, lastT = 0, ev = [0, 0, 0]; + const secs = S.map(([, head, val, more, c], i) => { + const g = el("g", {}, svg), body = el("g", {}, g); + const cp = el("clipPath", { id: `mg-byo-sec${i}` }, defs), cr = el("rect", { x: 430, width: 324 }, cp); + const halo = el("rect", { x: 436, width: 312, rx: 14, fill: "none", stroke: c, "stroke-width": 5, opacity: 0 }, g); + const bg = el("rect", { x: 436, width: 312, rx: 14, fill: "#fff", stroke: INK, "stroke-width": 2 }, body); + const inner = el("g", { "clip-path": `url(#mg-byo-sec${i})` }, body); + const dot = el("circle", { r: 8, fill: c, stroke: INK, "stroke-width": 2 }, inner); + const hd = txt(inner, 476, 0, head, { "font-size": 19, "font-weight": 700 }); + const vl = txt(inner, 476, 0, val, { "font-size": 19, "font-weight": 500 }); + const arrow = el("path", { stroke: MUTE, "stroke-width": 2.5, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, inner); + const mo = txt(inner, 500, 0, more, { "font-size": 17, "font-weight": 500, fill: MUTE }); + const ring = el("rect", { x: 430, width: 324, rx: 18, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, body); + const pick = () => { + if (pinned === i && lastT >= ALL) return; + snap = ev.slice(); pickAt = api.now(); pinned = i; + if (lastT < ALL) api.seek(ALL); else api.poke(); + }; + button(g, `${head} ${val}. ${more}`, pick); + g.addEventListener("focus", pick); + return { g, body, cr, halo, bg, dot, hd, vl, arrow, mo, ring, c, at: S[i][6] + 0.75 }; + }); + + return (t, now) => { + if (t < lastT - 0.5) { pinned = -1; snap = [0, 0, 0]; } + lastT = t; + hintT.setAttribute("opacity", seg(t, 2.0, 2.4)); + const te = seg(t, 0.2, 0.75); + term.setAttribute("transform", `translate(${-24 * (1 - oc(te))} 0)`); term.setAttribute("opacity", seg(te, 0, 0.35)); + tm.dots.forEach((D, j) => tf(D.d, D.x, D.y, ob(seg(t, 0.55 + j * 0.08, 0.9 + j * 0.08)))); + + /* the card arrives folded, then unfolds twice */ + const ce = seg(t, 0.35, 0.85); + card.setAttribute("transform", `translate(0 ${18 * (1 - oc(ce))})`); card.setAttribute("opacity", seg(ce, 0, 0.35)); + const k2 = bk(seg(t, 0.95, 1.45), 1.2), k3 = bk(seg(t, 1.3, 1.8), 1.2); + [[f2, k2], [f3, k3]].forEach(([F, k]) => { + F.g.setAttribute("transform", `translate(0 ${F.y0}) scale(1 ${Math.max(0.001, k)}) translate(0 ${-F.y0})`); + F.g.setAttribute("opacity", k > 0.01 ? 1 : 0); + F.shade.setAttribute("opacity", 0.9 * (1 - cl(k))); + F.edge.setAttribute("opacity", k > 0.01 && k < 0.98 ? 1 : 0); + }); + crease.forEach((n, i) => { const k = i ? k3 : k2; n.setAttribute("opacity", k > 0.05 ? 1 - seg(t, 1.9 + i * 0.1, 2.4 + i * 0.1) : 0); }); + outline.setAttribute("height", (P1 - Y0) + (P2 - P1) * Math.max(0, k2) + (Y1 - P2) * Math.max(0, k3)); + tf(csun, 738, 134, ob(seg(t, 0.7, 1.1)), 40 * breathe(t, END, 6) * seg(t, 9.6, 10)); + cap.setAttribute("opacity", seg(t, 1.8, 2.2)); + + /* commands type, output appears, a ribbon runs to its section */ + let cx = 80, cy = CY[0], typing = false; + cmds.forEach((C, i) => { + C.ps.setAttribute("opacity", seg(t, C.a - 0.35, C.a - 0.15)); + const k = type(C.n, C.s, t, C.a, C.b); + if (t >= C.a - 0.35) { cx = 80 + k * C.cw; cy = C.y; } + if (t >= C.a && t < C.b + 0.05) typing = true; + C.out.forEach((O, j) => O.n.setAttribute("width", O.w * oc(seg(t, C.b + 0.12 + j * 0.08, C.b + 0.4 + j * 0.08)))); + }); + const lastIn = cmds[2].b + 0.6; + lastPs.setAttribute("opacity", seg(t, lastIn, lastIn + 0.2)); + if (t >= lastIn) { cx = 80; cy = 450; } + tCur.setAttribute("x", cx + 2); tCur.setAttribute("y", cy - 16); + tCur.setAttribute("opacity", te > 0.6 && (typing || blink(t, END)) ? 1 : 0); + + /* expansion: autoplay opens Checks, a tap or focus opens another */ + const target = [0, 1, 2].map((i) => (pinned >= 0 ? (i === pinned ? 1 : 0) : (i === 1 ? 1 : 0))); + if (pinned >= 0) { const k = api.calm ? 1 : io(seg(now - pickAt, 0, 0.38)); ev = snap.map((s, i) => s + (target[i] - s) * k); } + else ev = [0, io(seg(t, AUTO, AUTO + 0.45)), 0]; + let y = TOP; + secs.forEach((Sc, i) => { + const h = H0 + HX * ev[i]; + const sk = seg(t, Sc.at, Sc.at + 0.5), on = sk > 0; + [Sc.bg, Sc.halo].forEach((n) => { n.setAttribute("y", y); n.setAttribute("height", h); }); + Sc.cr.setAttribute("y", y + 2); Sc.cr.setAttribute("height", h - 4); + Sc.ring.setAttribute("y", y - 6); Sc.ring.setAttribute("height", h + 12); + Sc.bg.setAttribute("fill", mix("#FFFFFF", Sc.c, 0.2 * ev[i])); + Sc.body.setAttribute("transform", `translate(436 ${y + h / 2}) scale(${0.92 + 0.08 * ob(sk)} ${0.8 + 0.2 * ob(sk)}) translate(-436 ${-(y + h / 2)})`); + Sc.g.setAttribute("opacity", on ? seg(sk, 0, 0.3) : 0); + const hk = seg(t, Sc.at + 0.02, Sc.at + 0.42), vk = seg(t, Sc.at + 0.11, Sc.at + 0.51); + Sc.hd.setAttribute("y", y + 32 + 10 * (1 - oc(hk))); Sc.hd.setAttribute("opacity", seg(hk, 0, 0.5)); + Sc.vl.setAttribute("y", y + 60 + 10 * (1 - oc(vk))); Sc.vl.setAttribute("opacity", seg(vk, 0, 0.5)); + tf(Sc.dot, 458, y + 25, ob(seg(t, Sc.at, Sc.at + 0.35))); + const mk = seg(ev[i], 0.35, 1); + Sc.arrow.setAttribute("d", `M480 ${y + 74} V${y + 84} H490`); Sc.arrow.setAttribute("opacity", mk); + Sc.mo.setAttribute("y", y + 90 + 6 * (1 - mk)); Sc.mo.setAttribute("opacity", mk); + const hb = seg(t, Sc.at, Sc.at + 0.6); + Sc.halo.setAttribute("opacity", hb > 0 && hb < 1 ? 1 - hb : 0); + Sc.halo.setAttribute("transform", `translate(592 ${y + h / 2}) scale(${1 + 0.05 * hb} ${1 + 0.25 * hb}) translate(-592 ${-(y + h / 2)})`); + Sc.ty = y + 38; + y += h + GAP; + }); + + /* ribbons follow their section */ + const flow = seg(t, 9.4, 9.9); + ribs.forEach((Rb, i) => { + const y0 = CY[i] + 6, y1 = secs[i].ty, d = `M300 ${y0} C362 ${y0}, 372 ${y1}, 436 ${y1}`; + [Rb.o, Rb.m, Rb.f].forEach((n) => n.setAttribute("d", d)); + const L = Rb.m.getTotalLength(), g = io(seg(t, S[i][6] + 0.1, S[i][6] + 0.8)); + [Rb.o, Rb.m].forEach((n) => { n.setAttribute("stroke-dasharray", `${L} ${L}`); n.setAttribute("stroke-dashoffset", L * (1 - g)); }); + Rb.g.setAttribute("opacity", g > 0 ? 1 : 0); + Rb.f.setAttribute("stroke-dashoffset", -((t - END) * 26) % 16); + Rb.f.setAttribute("opacity", 0.85 * flow); + }); + }; + }, + }; + + /* ---- 3. a thread message goes to the orchestrator, a temp worker replies and folds away ---- */ + SC.thread = { + dur: 12, still: 12, + build(svg, api) { + const END = 12, SEND = 2.8, RP = 2.7; + const hintT = hint(svg, "Tap Send"); + const MSG = "Fix the failing check on main", REP = "Fix ready for review"; + + /* the thread panel */ + const panel = el("g", {}, svg); + el("rect", { x: 36, y: 112, width: 432, height: 444, rx: 22, fill: "#fff", stroke: INK, "stroke-width": 3 }, panel); + el("path", { d: "M36 174 H468", stroke: INK, "stroke-width": 2.5 }, panel); + const ttl = txt(svg, 60, 154, "#standup", { "font-size": 23, "font-weight": 700 }); + + /* the message from You */ + const m1 = el("g", {}, svg); + const m1mk = el("g", {}, m1); el("rect", { x: -15, y: -15, width: 30, height: 30, rx: 9, fill: BLUE, stroke: INK, "stroke-width": 2.5 }, m1mk); tf(m1mk, 71, 285); + const m1n = txt(m1, 100, 292, "You", { "font-size": 19, "font-weight": 700 }); + const m1t = fit(txt(m1, 100, 323, MSG, { "font-size": 19, "font-weight": 500 }), 348); + const msgEnd = 100 + m1t.getComputedTextLength(); + const m1bg = el("rect", { x: 48, y: 262, width: 408, height: 76, rx: 12, fill: SKY, opacity: 0 }, svg); + svg.insertBefore(m1bg, m1); + + /* the threaded reply */ + const conn = el("path", { d: "M71 306 V352 Q71 372 91 372 H104", stroke: GREY, "stroke-width": 3, fill: "none", "stroke-linecap": "round" }, svg); + const CL = 46 + 31.4 + 13; + const r1 = el("g", {}, svg); + const r1mk = el("g", {}, r1); el("rect", { x: -13, y: -13, width: 26, height: 26, rx: 8, fill: "#fff", stroke: INK, "stroke-width": 2.5, "stroke-dasharray": "4 3" }, r1mk); tf(r1mk, 119, 372); + const r1n = txt(r1, 142, 379, "temp worker", { "font-size": 18, "font-weight": 700, fill: MUTE }); + const r1t = txt(r1, 142, 409, REP, { "font-size": 19, "font-weight": 500 }); + const r1bg = el("rect", { x: 96, y: 350, width: 360, height: 74, rx: 12, fill: MINT, opacity: 0 }, svg); + svg.insertBefore(r1bg, r1); + + /* composer */ + const comp = el("g", {}, svg); + el("rect", { x: 52, y: 472, width: 300, height: 52, rx: 14, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, comp); + const ct = fit(txt(comp, 66, 504, MSG, { "font-size": 17, "font-weight": 500 }), 270); + const cCur = el("rect", { width: 2.5, height: 22, fill: INK }, comp); + const btn = mkBtn(svg, "Send", 18); + + /* Michael, the orchestrator */ + const MC = [640, 200]; + const mich = el("g", {}, svg), mBody = el("g", {}, mich); + const mHalo = el("rect", { x: -125, y: -42, width: 250, height: 84, rx: 20, fill: "none", stroke: YEL, "stroke-width": 6, opacity: 0 }, mich); + el("rect", { x: -125, y: -42, width: 250, height: 84, rx: 20, fill: BLUE, stroke: INK, "stroke-width": 3 }, mBody); + const hub = el("g", {}, mBody); + [[-96, -14], [-96, 14], [-74, 0]].forEach(([x, y], i) => { if (i < 2) el("path", { d: `M${x} ${y} L-74 0`, stroke: INK, "stroke-width": 2.5 }, hub); }); + [[-96, -14], [-96, 14]].forEach(([x, y]) => el("circle", { cx: x, cy: y, r: 5.5, fill: "#fff", stroke: INK, "stroke-width": 2.2 }, hub)); + el("circle", { cx: -74, cy: 0, r: 8, fill: YEL, stroke: INK, "stroke-width": 2.5 }, hub); + fit(txt(mBody, -52, -3, "Michael", { "font-size": 23, "font-weight": 700 }), 160); + fit(txt(mBody, -52, 23, "(orchestrator)", { "font-size": 17, "font-weight": 600 }), 160); + const mDot = el("circle", { cx: 106, cy: -24, r: 6, fill: MINT, stroke: INK, "stroke-width": 2 }, mBody); + + /* the temp worker, its link, and the outline it leaves */ + const TC = [640, 404], TW = 196, TH = 100; + const ghost = el("rect", { x: TC[0] - TW / 2, y: TC[1] - TH / 2, width: TW, height: TH, rx: 14, fill: "none", stroke: MUTE, "stroke-width": 2, "stroke-dasharray": "7 7", opacity: 0 }, svg); + const link = el("path", { stroke: MUTE, "stroke-width": 2.5, "stroke-dasharray": "3 7", "stroke-linecap": "round", fill: "none" }, svg); + const tile = el("g", {}, svg), tIn = el("g", {}, tile); + el("rect", { x: -TW / 2, y: -TH / 2, width: TW, height: TH, rx: 14, fill: "#fff", stroke: INK, "stroke-width": 2.5, "stroke-dasharray": "8 6" }, tIn); + const tShade = el("rect", { x: -TW / 2, y: -TH / 2, width: TW, height: TH, rx: 14, fill: LIL_SOFT, opacity: 0 }, tIn); + const tBody = el("g", {}, tIn); + fit(mono(tBody, 0, -10, "temp worker", { "text-anchor": "middle", "font-size": 18, "font-weight": 700 }), 170); + el("rect", { x: -74, y: 12, width: 148, height: 16, rx: 8, fill: SOFT, stroke: INK, "stroke-width": 2 }, tBody); + const bar = el("rect", { x: -74, y: 12, width: 0, height: 16, rx: 8, fill: MINT, stroke: INK, "stroke-width": 2 }, tBody); + + /* envelopes */ + const A1 = [Math.min(440, msgEnd + 34), 316], C1 = [500, 330], B1 = [514, 202]; + const A2 = [540, 404], C2 = [470, 470], B2 = [404, 398]; + const e1 = mkEnv(svg), e2 = mkEnv(svg, MINT); + + let press = -99; + button(btn.g, "Send the message again", () => { press = api.now(); api.seek(RP); }); + + return (t, now) => { + const pe = seg(t, 0.15, 0.7); + rise(panel, pe, 22); + rise(ttl, seg(t, 0.3, 0.75), 12); + hintT.setAttribute("opacity", seg(t, 1.0, 1.4)); + const coe = seg(t, 0.45, 0.9); + rise(comp, coe, 14); + const be = seg(t, 0.55, 1.0), pd = Math.max(dip(seg(t, SEND, SEND + 0.3)), api.calm ? 0 : dip(seg(now, press, press + 0.3))); + tf(btn.g, 410, 498 + 12 * (1 - ob(be))); btn.g.setAttribute("opacity", seg(be, 0, 0.4)); + btn.body.setAttribute("transform", `translate(0 ${3 * pd}) scale(${1 - 0.06 * pd})`); + + /* typing in the composer, then the text lifts into the thread */ + type(ct, MSG, t, 1.2, 2.6); + const gone = seg(t, SEND + 0.02, SEND + 0.2); + ct.setAttribute("opacity", 1 - gone); ct.setAttribute("transform", `translate(0 ${-10 * gone})`); + if (gone >= 1) ct.textContent = ""; + const cw = gone >= 1 ? 0 : ct.getComputedTextLength(); + cCur.setAttribute("x", 66 + cw + 2); cCur.setAttribute("y", 486); + const typing = t >= 1.2 && t < 2.62; + cCur.setAttribute("opacity", coe > 0.6 && (gone > 0 && gone < 1 ? 0 : typing || blink(t, END, 1.0)) ? 1 : 0); + + const mk = seg(t, SEND + 0.05, SEND + 0.5), nk = seg(t, SEND + 0.12, SEND + 0.55), tk = seg(t, SEND + 0.2, SEND + 0.65); + tf(m1mk, 71, 285, ob(mk)); m1mk.setAttribute("opacity", mk > 0 ? 1 : 0); + [[m1n, nk], [m1t, tk]].forEach(([n, kk]) => { n.setAttribute("opacity", seg(kk, 0, 0.4)); n.setAttribute("transform", `translate(0 ${26 * (1 - oc(kk))})`); }); + const fl = seg(t, SEND + 0.1, SEND + 1.3); + m1bg.setAttribute("opacity", fl > 0 && fl < 1 ? 0.35 * (1 - fl) : 0); + + /* envelope one: message to Michael */ + const s1 = seg(t, 3.55, 3.8), f1 = seg(t, 3.75, 4.6); + if (t < 3.55 || f1 >= 1) e1.g.setAttribute("opacity", 0); + else { + const p = io(f1), [x, y] = f1 > 0 ? qb(A1, C1, B1, p) : A1; + tf(e1.g, x, y, (0.2 + 0.8 * ob(s1)) * (1 + 0.2 * Math.sin(Math.PI * p)) * (1 - 0.4 * seg(f1, 0.85, 1)), -8 + 18 * Math.sin(Math.PI * p)); + e1.g.setAttribute("opacity", 1 - seg(f1, 0.9, 1)); + } + + /* Michael: in, takes the envelope, then breathes */ + const me = seg(t, 0.7, 1.2), hit = seg(t, 4.55, 5.0), q = squash(hit); + const settle = seg(t, 9.8, 10.3), bob = -3 * Math.sin((2 * Math.PI * (t - END)) / 2.2) * settle; + tf(mich, MC[0], MC[1] + 16 * (1 - oc(me)) + bob, 0.6 + 0.4 * ob(me)); + mich.setAttribute("opacity", seg(me, 0, 0.3)); + mBody.setAttribute("transform", `scale(${1 + q} ${1 - q})`); + const mh = seg(t, 4.6, 5.3); + mHalo.setAttribute("opacity", mh > 0 && mh < 1 ? 1 - mh : 0); mHalo.setAttribute("transform", `scale(${1 + 0.12 * mh} ${1 + 0.3 * mh})`); + mDot.setAttribute("opacity", t < 4.6 || blink(t, END, 1.1) || t < 9.8 ? 1 : 0.25); + + /* the temp worker pops out of Michael */ + const sp = seg(t, 4.95, 5.5), spE = ob(sp), fo = seg(t, 8.75, 9.4); + const ty = MC[1] + 30 + (TC[1] - MC[1] - 30) * oc(sp), ts = 0.3 + 0.7 * spE; + tf(tile, TC[0], ty, ts); + tile.setAttribute("opacity", sp > 0 && fo < 1 ? seg(sp, 0, 0.2) * (1 - seg(fo, 0.75, 1)) : 0); + const fy = io(fo); + tIn.setAttribute("transform", `translate(0 ${-TH / 2}) scale(${1 - 0.12 * fy} ${Math.max(0.02, 1 - fy)}) translate(0 ${TH / 2})`); + tShade.setAttribute("opacity", 0.9 * fy); + tBody.setAttribute("opacity", 1 - seg(fo, 0, 0.3)); + bar.setAttribute("width", 148 * (0.42 * oc(seg(t, 5.65, 6.25)) + 0.58 * oc(seg(t, 6.45, 7.15)))); + bar.setAttribute("fill", mix(MINT, "#B5ECD2", dip(seg(t, 7.15, 7.45)))); + const top = ty - (TH / 2) * ts, lk = sp > 0 ? 1 - seg(fo, 0, 0.6) : 0; + link.setAttribute("d", `M${MC[0]} ${MC[1] + 44 + bob} V${Math.max(MC[1] + 44 + bob, top - 2)}`); + link.setAttribute("opacity", lk); + ghost.setAttribute("opacity", 0.55 * seg(fo, 0.5, 1)); + + /* envelope two: the worker's reply back into the thread */ + const s2 = seg(t, 7.3, 7.5), f2 = seg(t, 7.45, 8.25); + if (t < 7.3 || f2 >= 1) e2.g.setAttribute("opacity", 0); + else { + const p = io(f2), [x, y] = f2 > 0 ? qb(A2, C2, B2, p) : A2; + tf(e2.g, x, y, (0.2 + 0.8 * ob(s2)) * (1 + 0.2 * Math.sin(Math.PI * p)) * (1 - 0.4 * seg(f2, 0.8, 1)), 8 - 18 * Math.sin(Math.PI * p)); + e2.g.setAttribute("opacity", 1 - seg(f2, 0.85, 1)); + } + const cn = oc(seg(t, 8.05, 8.45)); + conn.setAttribute("stroke-dasharray", `${CL} ${CL}`); conn.setAttribute("stroke-dashoffset", CL * (1 - cn)); conn.setAttribute("opacity", cn > 0 ? 1 : 0); + const rk = seg(t, 8.2, 8.6), rn = seg(t, 8.28, 8.7), rt = seg(t, 8.36, 8.8); + tf(r1mk, 119, 372, ob(rk)); r1mk.setAttribute("opacity", rk > 0 ? 1 : 0); + [[r1n, rn], [r1t, rt]].forEach(([n, kk]) => { n.setAttribute("opacity", seg(kk, 0, 0.4)); n.setAttribute("transform", `translate(${-12 * (1 - oc(kk))} 0)`); }); + const rf = seg(t, 8.25, 9.6); + r1bg.setAttribute("opacity", rf > 0 && rf < 1 ? 0.35 * (1 - rf) : 0); + }; + }, + }; + + /* ---- player ---- */ + const q = new URLSearchParams(location.search), frozen = q.has("mgstill"), fixed = parseFloat(q.get("mgstill")); + const calm = matchMedia("(prefers-reduced-motion: reduce)").matches; + const mount = (fig) => { + const sc = SC[fig.dataset.scene], img = fig.querySelector("img"); if (!sc || !img) return; + const W = sc.w || 800, H = sc.h || 600; + const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "mg-svg", role: "group", "aria-label": img.alt || "" }); + svg.style.aspectRatio = `${W} / ${H}`; + el("rect", { width: W, height: H, fill: "#fff" }, svg); + img.replaceWith(svg); + let off = 0, raf = 0, seen = false, held = calm; + const base = performance.now(), clock = () => (performance.now() - base) / 1000; + let draw = () => {}; + const frame = () => { const now = clock(); draw(held ? sc.still : (((now + off) % sc.dur) + sc.dur) % sc.dur, now); }; + const tick = () => { cancelAnimationFrame(raf); frame(); if (seen && !held) raf = requestAnimationFrame(tick); }; + // with reduced motion a tap redraws the still frame with the new choice instead of starting playback + const api = { now: clock, calm, seek: (s) => { if (calm) return frame(); off = s - clock(); held = false; tick(); }, poke: () => { if (calm) return frame(); held = false; tick(); } }; + draw = sc.build(svg, api); + if (frozen) { draw(isNaN(fixed) ? sc.still : fixed, 0); return; } + new IntersectionObserver(([e]) => { + seen = e.isIntersecting; + if (seen) { tick(); if (!fig.dataset.sent && window.posthog) { fig.dataset.sent = 1; window.posthog.capture("blog_scene_view", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } } + else cancelAnimationFrame(raf); + }, { threshold: 0.25 }).observe(fig); + svg.addEventListener("click", () => { if (!fig.dataset.hit && window.posthog) { fig.dataset.hit = 1; window.posthog.capture("blog_scene_interact", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } }); + frame(); + }; + const go = () => document.querySelectorAll("figure.mg[data-scene]").forEach(mount); + const fonts = document.fonts && document.fonts.load ? Promise.all([document.fonts.load('700 32px "Space Grotesk"'), document.fonts.load('500 20px "Space Grotesk"'), document.fonts.load('700 20px "JetBrains Mono"')]).catch(() => {}) : Promise.resolve(); + const start = () => fonts.then(go, go); + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); +})(); diff --git a/blog/src/assets/media/build-your-own-claude-duet/schedule.png b/blog/src/assets/media/build-your-own-claude-duet/schedule.png new file mode 100644 index 0000000000..f9bbe7150b Binary files /dev/null and b/blog/src/assets/media/build-your-own-claude-duet/schedule.png differ diff --git a/blog/src/assets/media/build-your-own-claude-duet/thread.png b/blog/src/assets/media/build-your-own-claude-duet/thread.png new file mode 100644 index 0000000000..163148a076 Binary files /dev/null and b/blog/src/assets/media/build-your-own-claude-duet/thread.png differ diff --git a/blog/src/assets/media/claude-duet-alternatives/hero.png b/blog/src/assets/media/claude-duet-alternatives/hero.png new file mode 100644 index 0000000000..93ec521870 Binary files /dev/null and b/blog/src/assets/media/claude-duet-alternatives/hero.png differ diff --git a/blog/src/assets/media/claude-duet-alternatives/motion.css b/blog/src/assets/media/claude-duet-alternatives/motion.css new file mode 100644 index 0000000000..fa72352797 --- /dev/null +++ b/blog/src/assets/media/claude-duet-alternatives/motion.css @@ -0,0 +1,13 @@ +/* Bright coded scenes for "Claude Duet alternatives". Pairs with motion.js. */ +.prose figure.mg { margin: 42px 0; } +.prose figure.mg img, .mg .mg-svg { display: block; width: 100%; height: auto; border: 1px solid #1A1320; border-radius: 18px; background: #fff; } +.mg figcaption { font: 500 13px/1.5 "Space Grotesk", system-ui, sans-serif; color: #6B5878; padding-top: 10px; text-align: center; } +.mg .mg-svg { touch-action: manipulation; -webkit-tap-highlight-color: transparent; } +.mg .mg-svg text { -webkit-user-select: none; user-select: none; pointer-events: none; } +.mg .mg-hit { cursor: pointer; outline: none; } +.mg [role="slider"] { cursor: grab; touch-action: none; } +.mg .mg-ring { stroke-opacity: 0; transition: stroke-opacity 0.15s; } +.mg .mg-hit:focus-visible .mg-ring { stroke-opacity: 1; } +.mg [role="slider"]:focus-visible circle:nth-child(2) { stroke-width: 5; } +@media (hover: hover) { .mg .mg-hit:hover .mg-ring { stroke-opacity: 0.5; } } +@media (prefers-reduced-motion: reduce) { .mg .mg-ring { transition: none; } } diff --git a/blog/src/assets/media/claude-duet-alternatives/motion.js b/blog/src/assets/media/claude-duet-alternatives/motion.js new file mode 100644 index 0000000000..3288470f8d --- /dev/null +++ b/blog/src/assets/media/claude-duet-alternatives/motion.js @@ -0,0 +1,620 @@ +/* Bright coded scenes for "Claude Duet alternatives". Vanilla JS, no library. +
holds a still image; this script swaps in a live SVG. + Plays only in view, honours reduced motion, "?mgstill" freezes each scene on its still frame. */ +(() => { + const NS = "http://www.w3.org/2000/svg"; + const INK = "#1A1320", YEL = "#FFCA54", BLUE = "#6C8EF5", LIL = "#B69CFF", MINT = "#7FD8AE", SKY = "#9AD8FF", SOFT = "#F1ECF9", MUTE = "#7A6A88"; + const SKY_SOFT = "#DDF1FF", YEL_SOFT = "#FFE9B0", DESK = "#FFE7A8"; + const SANS = '"Space Grotesk", system-ui, sans-serif', MONO = '"JetBrains Mono", ui-monospace, monospace'; + const el = (t, a = {}, p) => { const n = document.createElementNS(NS, t); for (const k in a) n.setAttribute(k, a[k]); if (p) p.appendChild(n); return n; }; + const txt = (p, x, y, s, a = {}) => { const n = el("text", Object.assign({ x, y, "font-family": SANS, "font-size": 24, "font-weight": 600, fill: INK }, a), p); n.textContent = s; return n; }; + const cl = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v)); + const seg = (t, a, b) => cl((t - a) / (b - a)); + const oc = (x) => 1 - Math.pow(1 - x, 3); + const ob = (x) => { const c = 1.70158; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); }; + const io = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); + const ios = (x) => 0.5 - 0.5 * Math.cos(Math.PI * x); + const tf = (n, x, y, s = 1, r = 0) => n.setAttribute("transform", `translate(${x} ${y}) rotate(${r}) scale(${s})`); + const hex = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + const mix = (a, b, k) => { const A = hex(a), B = hex(b); return "#" + A.map((v, i) => Math.round(v + (B[i] - v) * cl(k)).toString(16).padStart(2, "0")).join(""); }; + /* a soft idle beat: 0 at rest, a smooth hump for the first w of each period P; always at rest when t = end */ + const bump = (t, end, P, d = 0, w = 0.5) => { if (!isFinite(t)) return 0; const u = ((((t - end - d) / P) % 1) + 1) % 1; return u < w ? Math.sin(Math.PI * u / w) : 0; }; + const button = (g, label, fn) => { + g.setAttribute("role", "button"); g.setAttribute("tabindex", "0"); g.setAttribute("aria-label", label); g.classList.add("mg-hit"); + g.addEventListener("click", fn); + g.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); } }); + }; + const hint = (svg, s, y = 60) => txt(svg, 756, y, s, { "text-anchor": "end", "font-size": 16, fill: MUTE, "font-weight": 500 }); + /* a rounded token centred on its origin, sized to its label, with an optional coloured dot */ + const token = (p, label, fill, dot, fs = 18) => { + const g = el("g", {}, p), inner = el("g", {}, g); + const halo = el("rect", { y: -17, height: 34, rx: 17, fill: "none", stroke: SKY, "stroke-width": 4, opacity: 0 }, inner); + const r = el("rect", { y: -17, height: 34, rx: 17, fill, stroke: INK, "stroke-width": 2.5 }, inner); + const padL = dot ? 31 : 16; + const t = txt(inner, 0, 6, label, { "font-size": fs, "font-weight": 700 }); + const w = Math.ceil(t.getComputedTextLength()) + padL + 16; + t.setAttribute("x", -w / 2 + padL); + [r, halo].forEach((n) => { n.setAttribute("x", -w / 2); n.setAttribute("width", w); }); + if (dot) el("circle", { cx: -w / 2 + 17, cy: 0, r: 7, fill: dot, stroke: INK, "stroke-width": 2 }, inner); + return { g, inner, halo, w }; + }; + const plen = (pts) => pts.slice(1).reduce((s, p, i) => s + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1]), 0); + const pat = (pts, d) => { + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1], b = pts[i], L = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (d <= L || i === pts.length - 1) { const k = L ? cl(d / L) : 0; return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]; } + d -= L; + } + return pts[pts.length - 1]; + }; + const pd = (pts) => "M" + pts.map((p) => p.join(" ")).join(" L"); + const dash = (n, L, p) => { n.setAttribute("stroke-dasharray", `${L} ${L + 2}`); n.setAttribute("stroke-dashoffset", L * (1 - p)); n.setAttribute("opacity", p > 0.001 ? 1 : 0); }; + + const SC = {}; + + /* ---- title card, 16 by 9: a stuck waitlist beside four open lanes ---- */ + SC.hero = { + w: 960, h: 540, dur: 10, still: 10, + build(svg) { + const END = 10; + const pill = el("g", {}, svg); + const pr = el("rect", { y: -20, height: 40, rx: 20, fill: YEL, stroke: INK, "stroke-width": 2.5 }, pill); + const pt = txt(pill, 0, 7, "Alternatives", { "text-anchor": "middle", "font-size": 19, "font-weight": 700 }); + const pw = Math.ceil(pt.getComputedTextLength()) + 40; + pr.setAttribute("x", -pw / 2); pr.setAttribute("width", pw); + const l1g = el("g", {}, svg), l2g = el("g", {}, svg); + const l1 = txt(l1g, 60, 198, "Claude Duet", { "font-size": 76, "font-weight": 700 }); + const l2 = txt(l2g, 60, 276, "alternatives", { "font-size": 76, "font-weight": 700 }); + const tmax = Math.max(l1.getComputedTextLength(), l2.getComputedTextLength()); + if (tmax > 486) [l1, l2].forEach((n) => n.setAttribute("font-size", (76 * 486) / tmax)); + const UL = Math.round(l2.getComputedTextLength() * 0.62); + const under = el("path", { d: `M66 300 H${66 + UL}`, stroke: YEL, "stroke-width": 14, "stroke-linecap": "round", fill: "none" }, svg); + const subg = el("g", {}, svg); + const sub = txt(subg, 64, 356, "Proactive agents with no waitlist", { "font-size": 27, "font-weight": 500, fill: MUTE }); + if (sub.getComputedTextLength() > 490) sub.setAttribute("font-size", (27 * 490) / sub.getComputedTextLength()); + const chk = txt(svg, 64, 476, "Checked 22 Oct 2026", { "font-size": 18, "font-weight": 500, fill: MUTE }); + + /* the waitlist: a post, a sign and a short queue of paper tickets */ + const floorS = el("path", { d: "M606 216 H904", stroke: SOFT, "stroke-width": 14, "stroke-linecap": "round", fill: "none" }, svg); + const floorI = el("path", { d: "M606 216 H904", stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round", fill: "none" }, svg); + const post = el("path", { d: "M640 214 V106", stroke: INK, "stroke-width": 5, "stroke-linecap": "round", fill: "none" }, svg); + const sign = el("g", {}, svg); + const sr = el("rect", { y: -48, height: 44, rx: 12, fill: LIL, stroke: INK, "stroke-width": 2.5 }, sign); + const st = txt(sign, 0, -19, "Waitlist", { "text-anchor": "middle", "font-size": 21, "font-weight": 700 }); + const sw = Math.ceil(st.getComputedTextLength()) + 40; + sr.setAttribute("x", -sw / 2); sr.setAttribute("width", sw); + el("circle", { cx: 0, cy: -4, r: 4.5, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, sign); + const TILT = [-4, 3, -2, 4]; + const tickets = [0, 1, 2, 3].map((i) => { + const g = el("g", {}, svg); + el("path", { d: "M-23 -64 H23 V-38 A6 6 0 0 0 23 -26 V0 H-23 V-26 A6 6 0 0 0 -23 -38 Z", fill: YEL, stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, g); + el("path", { d: "M-15 -32 H15", stroke: INK, "stroke-width": 2, "stroke-dasharray": "3 4" }, g); + el("rect", { x: -11, y: -54, width: 22, height: 8, rx: 4, fill: "#fff", stroke: INK, "stroke-width": 1.5 }, g); + txt(g, 0, -7, String(i + 1), { "text-anchor": "middle", "font-family": MONO, "font-size": 21, "font-weight": 700 }); + return { g, x: 686 + i * 56 }; + }); + + /* four lanes that light up, each tagged Open now */ + const defs = el("defs", {}, svg); + const lanes = [0, 1, 2, 3].map((i) => { + const y = 236 + i * 58, g = el("g", {}, svg); + const cp = el("clipPath", { id: `mg-dalt-lane${i}` }, defs); + const wipe = el("rect", { x: 588, y: y - 2, width: 0, height: 50 }, cp); + el("rect", { x: 590, y, width: 326, height: 46, rx: 23, fill: SOFT }, g); + el("rect", { x: 590, y, width: 326, height: 46, rx: 23, fill: SKY, "clip-path": `url(#mg-dalt-lane${i})` }, g); + el("rect", { x: 590, y, width: 326, height: 46, rx: 23, fill: "none", stroke: INK, "stroke-width": 2.5 }, g); + const halo = el("circle", { cx: 616, cy: y + 23, r: 10, fill: "none", stroke: YEL, "stroke-width": 4, opacity: 0 }, g); + const lamp = el("circle", { cx: 616, cy: y + 23, r: 10, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, g); + const chev = el("path", { d: [646, 664, 682].map((x) => `M${x} ${y + 15} L${x + 8} ${y + 23} L${x} ${y + 31}`).join(" "), stroke: INK, "stroke-width": 3, "stroke-linecap": "round", "stroke-linejoin": "round", fill: "none" }, g); + const tag = token(svg, "Open now", MINT, null, 17); + return { g, wipe, halo, lamp, chev, tag, y, at: 3.0 + i * 0.55 }; + }); + + return (t) => { + const a = ob(seg(t, 0.15, 0.6)); + tf(pill, 64 + pw / 2, 82, 0.6 + 0.4 * a); pill.setAttribute("opacity", seg(t, 0.15, 0.3)); + [l1g, l2g].forEach((g, i) => { const k = seg(t, 0.32 + i * 0.1, 0.92 + i * 0.1); tf(g, 0, 26 * (1 - oc(k))); g.setAttribute("opacity", seg(k, 0, 0.4)); }); + dash(under, UL, oc(seg(t, 0.85, 1.4))); + const ks = seg(t, 0.62, 1.2); tf(subg, 0, 16 * (1 - oc(ks))); subg.setAttribute("opacity", seg(ks, 0, 0.5)); + chk.setAttribute("opacity", seg(t, 0.8, 1.2)); + + const fl = oc(seg(t, 0.9, 1.5)); dash(floorS, 298, fl); dash(floorI, 298, fl); + dash(post, 108, oc(seg(t, 1.0, 1.35))); + tf(sign, 640, 106, ob(seg(t, 1.25, 1.7)), 0); + const settle = seg(t, 5.0, 5.4); + tickets.forEach((k, i) => { + const e = seg(t, 1.35 + i * 0.1, 1.9 + i * 0.1); + const nud = bump(t, END, 2.5, i * 0.09, 0.4) * settle; + tf(k.g, k.x - 7 * nud, 214 - 44 * (1 - oc(e)), 0.86 + 0.14 * ob(e), TILT[i] + (1 - ob(e)) * -16 - 3 * nud); + k.g.setAttribute("opacity", seg(e, 0, 0.3)); + }); + lanes.forEach((L, i) => { + const e = seg(t, 1.7 + i * 0.09, 2.3 + i * 0.09); + tf(L.g, 40 * (1 - ob(e)), 0); L.g.setAttribute("opacity", seg(e, 0, 0.4)); + L.wipe.setAttribute("width", 332 * oc(seg(t, L.at, L.at + 0.45))); + const on = seg(t, L.at, L.at + 0.15); + L.lamp.setAttribute("fill", mix("#ffffff", YEL, on)); + L.chev.setAttribute("opacity", 0.25 + 0.75 * on); + const burst = seg(t, L.at, L.at + 0.6), pulse = bump(t, END, 1.8, i * 0.14, 0.5) * settle; + const hr = burst < 1 ? 10 + 18 * oc(burst) : 10 + 9 * pulse, ho = burst < 1 ? (burst > 0 ? 1 - burst : 0) : 0.75 * pulse; + L.halo.setAttribute("r", hr); L.halo.setAttribute("opacity", ho); + const p = seg(t, L.at + 0.25, L.at + 0.7); + tf(L.tag.g, 916 - 13 - L.tag.w / 2, L.y + 23, (0.4 + 0.6 * ob(p)) * (1 + 0.04 * pulse), -10 * (1 - ob(p))); + L.tag.g.setAttribute("opacity", seg(p, 0, 0.3)); + }); + }; + }, + }; + + /* ---- 1. where each tool runs, and what happens when the laptop lid shuts ---- */ + SC.where = { + dur: 13, still: 13, + build(svg, api) { + const END = 13, FLIP = 6.4; + hint(svg, "Flip the switch", 50); + /* switch */ + const sw = el("g", {}, svg); + const track = el("rect", { x: 0, y: -17, width: 64, height: 34, rx: 17, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, sw); + const knob = el("circle", { cx: 17, cy: 0, r: 12, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, sw); + const swl = txt(sw, 80, 8, "Laptop lid closed", { "font-size": 22, "font-weight": 700 }); + const ring = el("rect", { x: -9, y: -26, width: 80 + Math.ceil(swl.getComputedTextLength()) + 22, height: 52, rx: 26, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, sw); + ring.parentNode.insertBefore(el("rect", { x: -9, y: -26, width: ring.getAttribute("width"), height: 52, rx: 26, fill: "#fff", opacity: 0 }), track); + + /* cloud: outline layer under a fill layer gives one merged shape */ + const cloudS = el("g", {}, svg), cloudF = el("g", {}, svg); + const SH = [["r", 50, 160, 700, 142, 71, 400, 231], ["c", 170, 160, 44], ["c", 320, 146, 62], ["c", 500, 152, 56], ["c", 640, 164, 40]]; + const cloud = SH.map((s, i) => { + const mk = (p, a) => s[0] === "r" ? el("rect", Object.assign({ x: s[1], y: s[2], width: s[3], height: s[4], rx: s[5] }, a), p) : el("circle", Object.assign({ cx: s[1], cy: s[2], r: s[3] }, a), p); + return { a: mk(cloudS, { fill: INK, stroke: INK, "stroke-width": 6 }), b: mk(cloudF, { fill: SKY_SOFT }), cx: s[0] === "r" ? s[6] : s[1], cy: s[0] === "r" ? s[7] : s[2], i }; + }); + const cLab = txt(svg, 96, 210, "A vendor's cloud", { "font-size": 22, "font-weight": 700 }); + const still = el("g", {}, svg); + const sr = el("rect", { y: -17, height: 34, rx: 17, fill: MINT, stroke: INK, "stroke-width": 2.5 }, still); + const stt = txt(still, 0, 6, "Still working", { "font-size": 18, "font-weight": 700 }); + const stW = Math.ceil(stt.getComputedTextLength()) + 46; + sr.setAttribute("x", -stW / 2); sr.setAttribute("width", stW); stt.setAttribute("x", -stW / 2 + 32); + const sdot = el("circle", { cx: -stW / 2 + 17, cy: 0, r: 5, fill: INK }, still); + + /* desk zone */ + const desk = el("g", {}, svg), deskTok = el("g", {}, svg); + const dLab = txt(svg, 60, 360, "Hardware you control", { "font-size": 22, "font-weight": 700 }); + const deskBody = el("g", {}, desk); + [[76, 92], [708, 724]].forEach(([a, b]) => el("rect", { x: a, y: 548, width: b - a, height: 44, rx: 4, fill: YEL, stroke: INK, "stroke-width": 2.5 }, deskBody)); + el("rect", { x: 44, y: 534, width: 712, height: 18, rx: 5, fill: YEL, stroke: INK, "stroke-width": 2.5 }, deskBody); + el("path", { d: "M74 480 H726 L756 534 H44 Z", fill: DESK, stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, deskBody); + const mon = el("g", {}, desk); + el("rect", { x: 620, y: 448, width: 20, height: 26, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, mon); + el("rect", { x: 594, y: 472, width: 72, height: 9, rx: 4, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, mon); + const screen = el("rect", { x: 540, y: 334, width: 180, height: 116, rx: 12, fill: SKY_SOFT, stroke: INK, "stroke-width": 3 }, mon); + const bars = el("g", {}, mon); + [[562, 360, 100, BLUE], [562, 384, 136, SKY], [562, 408, 76, LIL]].forEach(([x, y, w, c]) => el("rect", { x, y, width: w, height: 11, rx: 5.5, fill: c }, bars)); + const paused = el("g", {}, svg); + const pr = el("rect", { x: 0, y: -18, height: 36, rx: 18, fill: SOFT, stroke: MUTE, "stroke-width": 2 }, paused); + el("rect", { x: 16, y: -8, width: 5, height: 16, rx: 2, fill: MUTE }, paused); + el("rect", { x: 25, y: -8, width: 5, height: 16, rx: 2, fill: MUTE }, paused); + const pt = txt(paused, 42, 6, "Paused until it wakes", { "font-size": 18, "font-weight": 700, fill: MUTE }); + pr.setAttribute("width", 42 + Math.ceil(pt.getComputedTextLength()) + 18); + + const TOK = [["Claude Duet", YEL, 0, 262, 0], ["Claude Cowork", BLUE, 0, 262, 0], ["Munder Difflin", MINT, 0, 508, 1], ["OpenClaw", SKY, 0, 508, 1], ["Hermes Agent", LIL, 0, 508, 1]]; + const toks = TOK.map(([s, c, x, y, onDesk], i) => { + const k = token(onDesk ? deskTok : svg, s, "#fff", c); + return Object.assign(k, { x, y, onDesk, s0: 1.05 + i * 0.5, drop: onDesk ? 40 : 30 }); + }); + /* rows: the cloud pair centred in the cloud, the desk trio spread left of the monitor */ + const row = (ks, a, b) => { const tot = ks.reduce((q, k) => q + k.w, 0), gap = (b - a - tot) / (ks.length + 1); let x = a + gap; ks.forEach((k) => { k.x = x + k.w / 2; x += k.w + gap; }); }; + { const w = toks[0].w + toks[1].w + 40; row(toks.slice(0, 2), 400 - w / 2 - 40, 400 + w / 2 + 40); } + row(toks.slice(2), 56, 600); + + let user = null, from = 0, at = 0, lastT = 0, curV = 0; + const flip = () => { + const on = user !== null ? user : lastT >= FLIP; + from = curV; user = !on; at = api.now(); + api.poke(); + }; + button(sw, "Laptop lid closed", flip); + sw.setAttribute("role", "switch"); + + return (t, now) => { + if (t < lastT - 0.5) user = null; + lastT = t; + let v; + if (user === null) v = io(seg(t, FLIP, FLIP + 0.6)); + else v = api.calm ? +user : from + (+user - from) * io(seg(now - at, 0, 0.5)); + curV = v; + sw.setAttribute("aria-checked", (user !== null ? user : t >= FLIP) ? "true" : "false"); + const se = seg(t, 0.15, 0.6); tf(sw, 44 - 24 * (1 - oc(se)), 44); sw.setAttribute("opacity", se); + knob.setAttribute("cx", 17 + 30 * v); + track.setAttribute("fill", mix(SOFT, LIL, v)); + + cloud.forEach((c) => { + const k = c.i === 0 ? oc(seg(t, 0.2, 0.7)) : ob(seg(t, 0.35 + c.i * 0.08, 0.8 + c.i * 0.08)); + const tr = c.i === 0 ? `translate(${c.cx} ${c.cy}) scale(${0.7 + 0.3 * k} ${0.85 + 0.15 * k}) translate(${-c.cx} ${-c.cy})` : `translate(${c.cx} ${c.cy}) scale(${k}) translate(${-c.cx} ${-c.cy})`; + [c.a, c.b].forEach((n) => { n.setAttribute("transform", tr); n.setAttribute("opacity", c.i === 0 ? seg(t, 0.2, 0.45) : k > 0 ? 1 : 0); }); + }); + const cl1 = seg(t, 0.75, 1.15); cLab.setAttribute("opacity", cl1); cLab.setAttribute("transform", `translate(0 ${10 * (1 - oc(cl1))})`); + + const de = seg(t, 0.35, 0.95); + deskBody.setAttribute("transform", `translate(0 ${-18 * (1 - oc(de))})`); deskBody.setAttribute("opacity", seg(de, 0, 0.4)); + const me = ob(seg(t, 0.6, 1.1)); + mon.setAttribute("transform", `translate(630 480) scale(${me}) translate(-630 -480)`); + const dl = seg(t, 0.85, 1.25); dLab.setAttribute("opacity", dl); dLab.setAttribute("transform", `translate(0 ${10 * (1 - oc(dl))})`); + desk.setAttribute("opacity", 1 - 0.6 * v); deskTok.setAttribute("opacity", 1 - 0.4 * v); + screen.setAttribute("fill", mix(SKY_SOFT, SOFT, v)); + bars.setAttribute("opacity", 1 - v); + + const live = seg(t, 4.0, 4.4); + toks.forEach((k, i) => { + const d = seg(t, k.s0, k.s0 + 0.42), L = seg(t, k.s0 + 0.42, k.s0 + 0.95); + const sq = L > 0 && L < 1 ? Math.sin(L * Math.PI * 2) * (1 - L) * 0.14 : 0; + let bob = 0, sc = 1, ho = 0; + if (k.onDesk) bob = -3 * bump(t, END, 1.6, i * 0.2, 0.5) * live * (1 - v); + else { const p = bump(t, END, 1.6, i * 0.25, 0.5) * live; sc = 1 + 0.06 * p; ho = 0.85 * p; } + tf(k.g, k.x, k.y - k.drop * (1 - d * d) + bob, sc); + k.g.setAttribute("opacity", seg(t, k.s0, k.s0 + 0.2)); + k.inner.setAttribute("transform", `translate(0 17) scale(${1 + sq} ${1 - sq}) translate(0 -17)`); + k.halo.setAttribute("opacity", ho); + k.halo.setAttribute("transform", `scale(${1 + 0.1 * ho} ${1 + 0.35 * ho})`); + }); + const pv = cl(v * 1.4), sv = cl((v - 0.2) / 0.8); + tf(paused, 60, 408, 0.8 + 0.2 * ob(pv)); paused.setAttribute("opacity", pv); + tf(still, 706 - stW / 2, 205, 0.8 + 0.2 * ob(sv)); still.setAttribute("opacity", sv); + sdot.setAttribute("opacity", 0.45 + 0.55 * (1 - bump(t, END, 1.6, 0, 0.5))); + }; + }, + }; + + /* ---- 2. a 24 hour dial: when each tool acts without a prompt ---- */ + SC.start = { + dur: 13, still: 13, + build(svg, api) { + const END = 13, C = [598, 312], R = 160, SWEEP = [2.2, 8.2], AUTO = 9.0, MD = 2; + hint(svg, "Tap a tool"); + const TOOLS = [ + ["Claude Duet:", "morning brief, then checks every few hours", YEL, 132, [8, 11, 14, 17, 20]], + ["Claude Cowork:", "scheduled tasks", BLUE, 110, [10, 19]], + ["Munder Difflin:", "schedules", MINT, 88, [6, 13, 22]], + ["Hermes Agent:", "cron scheduler", LIL, 66, [3, 16]], + ]; + const P = (h, r) => { const a = (h / 24) * Math.PI * 2; return [C[0] + r * Math.sin(a), C[1] - r * Math.cos(a)]; }; + + /* dial */ + const dial = el("g", {}, svg); + el("circle", { cx: C[0], cy: C[1], r: R, fill: "#fff", stroke: INK, "stroke-width": 3 }, dial); + const trail = el("path", { fill: YEL_SOFT, opacity: 0 }, dial); + const tracks = TOOLS.map(([, , , r]) => el("circle", { cx: C[0], cy: C[1], r, fill: "none", stroke: SOFT, "stroke-width": 14, transform: `rotate(-90 ${C[0]} ${C[1]})` }, dial)); + const ticks = []; + for (let h = 0; h < 24; h++) { + const a = P(h, h % 6 ? 151 : 143), b = P(h, 158); + ticks.push(el("path", { d: `M${a[0]} ${a[1]} L${b[0]} ${b[1]}`, stroke: INK, "stroke-width": h % 6 ? 2 : 3.5, "stroke-linecap": "round" }, dial)); + } + const labs = [0, 6, 12, 18].map((h) => { const p = P(h, 184); return txt(svg, p[0], p[1] + 7, String(h), { "text-anchor": "middle", "font-family": MONO, "font-size": 20, "font-weight": 700 }); }); + const marks = []; + TOOLS.forEach(([, , col, r, hours], ti) => hours.forEach((h) => { + const [x, y] = P(h, r), g = el("g", {}, svg); + const burst = el("circle", { r: 9, fill: "none", stroke: col, "stroke-width": 3, opacity: 0 }, g); + const halo = el("circle", { r: 9, fill: "none", stroke: INK, "stroke-width": 2, opacity: 0 }, g); + const dot = el("circle", { r: 9, fill: col, stroke: INK, "stroke-width": 2.5 }, g); + marks.push({ g, burst, halo, dot, x, y, h, ti, col, n: marks.length }); + })); + const hand = el("path", { d: "M0 12 V-148", stroke: INK, "stroke-width": 4, "stroke-linecap": "round" }, svg); + const hub = el("circle", { cx: C[0], cy: C[1], r: 8, fill: YEL, stroke: INK, "stroke-width": 2.5 }, svg); + + /* chips */ + const chips = []; + TOOLS.forEach(([name, rest, col], i) => { + const g = el("g", {}, svg); + const sh = el("rect", { x: 0, y: 0, width: 376, rx: 16, fill: col, stroke: INK, "stroke-width": 2.5, opacity: 0 }, g); + sh.setAttribute("width", 364); + const body = el("g", {}, g); + const box = el("rect", { x: 0, y: 0, width: 364, rx: 16, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, body); + const mk = (y) => el("text", { x: 50, y, "font-family": SANS, "font-size": 18, fill: INK }, body); + const sp = (ln, s, b) => { const n = el("tspan", { "font-weight": b ? 700 : 500 }, ln); n.textContent = s; return n; }; + let ln = mk(33), n = 1; + sp(ln, name, true); + rest.split(" ").forEach((w) => { + const s = sp(ln, " " + w, false); + if (ln.getComputedTextLength() > 296) { s.remove(); ln = mk(33 + n * 26); n++; sp(ln, w, false); } + }); + const h = 33 + (n - 1) * 26 + 21; + [sh, box].forEach((r) => r.setAttribute("height", h)); + el("circle", { cx: 26, cy: 27, r: 9, fill: col, stroke: INK, "stroke-width": 2.5 }, body); + el("rect", { x: -7, y: -7, width: 378, height: h + 14, rx: 22, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, body); + chips.push({ g, sh, body, h, label: name + " " + rest }); + }); + const total = chips.reduce((s, c) => s + c.h, 0) + 3 * 20; + let y0 = C[1] - total / 2; + chips.forEach((c) => { c.y = y0; y0 += c.h + 20; }); + + let pinned = -1, lastT = 0, at = 0, from = [0, 0, 0, 0, 0]; + const cur = [0, 0, 0, 0, 0]; + chips.forEach((c, i) => { + const pick = () => { if (pinned === i) return; from = cur.slice(); pinned = i; at = api.now(); api.poke(); }; + button(c.g, c.label, pick); + c.g.addEventListener("focus", pick); + }); + const sector = (a) => { + a = Math.min(a, Math.PI * 2 - 0.0005); + const e = P((a / (Math.PI * 2)) * 24, 150); + return `M${C[0]} ${C[1]} L${C[0]} ${C[1] - 150} A150 150 0 ${a > Math.PI ? 1 : 0} 1 ${e[0]} ${e[1]} Z`; + }; + + return (t, now) => { + if (t < lastT - 0.5) pinned = -1; + lastT = t; + /* focus levels: four chips plus a global dim */ + let f; + if (pinned < 0) { const v = io(seg(t, AUTO, AUTO + 0.5)); f = [0, 0, v, 0, v]; } + else { const v = api.calm ? 1 : io(seg(now - at, 0, 0.35)); f = from.map((x, i) => x + ((i === pinned || i === 4 ? 1 : 0) - x) * v); } + f.forEach((x, i) => (cur[i] = x)); + const g = f[4]; + + const de = seg(t, 0.15, 0.7); + dial.setAttribute("transform", `translate(${C[0]} ${C[1]}) scale(${0.8 + 0.2 * ob(de)}) translate(${-C[0]} ${-C[1]})`); + dial.setAttribute("opacity", seg(de, 0, 0.3)); + tracks.forEach((n, i) => dash(n, 2 * Math.PI * TOOLS[i][3], oc(seg(t, 0.6 + i * 0.1, 1.2 + i * 0.1)))); + ticks.forEach((n, h) => n.setAttribute("opacity", seg(t, 0.4 + h * 0.02, 0.6 + h * 0.02))); + labs.forEach((n, j) => { const k = ob(seg(t, 0.6 + j * 0.08, 1.0 + j * 0.08)); const x = +n.getAttribute("x"), y = +n.getAttribute("y") - 7; n.setAttribute("transform", `translate(${x} ${y}) scale(${k}) translate(${-x} ${-y})`); n.setAttribute("opacity", k > 0 ? 1 : 0); }); + + const sp = ios(seg(t, SWEEP[0], SWEEP[1])), hh = sp * 24; + tf(hand, C[0], C[1], 1, sp * 360); + const he = seg(t, 1.7, 2.1); hand.setAttribute("opacity", he); hub.setAttribute("opacity", seg(t, 0.5, 0.8)); + trail.setAttribute("d", sector(sp * Math.PI * 2)); + trail.setAttribute("opacity", sp > 0 ? 0.75 * (1 - seg(t, SWEEP[1], SWEEP[1] + 0.6)) : 0); + + marks.forEach((m) => { + const e = seg(t, 1.2 + m.n * 0.06, 1.65 + m.n * 0.06), k = t >= SWEEP[1] ? 1 : seg(hh - m.h, 0, 1.4); + const fi = f[m.ti], dim = 1 - 0.82 * g * (1 - fi); + const s = (0.72 * ob(e) + 0.28 * k + 0.6 * Math.sin(Math.PI * k)) * (1 + 0.25 * fi); + tf(m.g, m.x, m.y, s); + m.g.setAttribute("opacity", (e > 0 ? 1 : 0) * dim); + m.dot.setAttribute("fill", mix("#ffffff", m.col, 0.45 + 0.55 * k)); + m.burst.setAttribute("r", 9 + 18 * k); m.burst.setAttribute("opacity", k > 0 && k < 1 ? (1 - k) : 0); + const p = bump(t, END, 1.6, (m.n % 3) * 0.15, 0.5) * seg(t, AUTO + 0.4, AUTO + 0.8) * fi; + m.halo.setAttribute("r", 9 + 3 + 8 * p); m.halo.setAttribute("opacity", fi * (0.35 + 0.5 * p)); + }); + + chips.forEach((c, i) => { + const e = seg(t, 0.3 + i * 0.1, 0.85 + i * 0.1); + tf(c.g, 24 - 22 * (1 - ob(e)), c.y); + c.g.setAttribute("opacity", seg(e, 0, 0.35) * (1 - 0.42 * g * (1 - f[i]))); + c.body.setAttribute("transform", `translate(${-4 * f[i]} ${-5 * f[i]})`); + c.sh.setAttribute("opacity", f[i]); + }); + }; + }, + }; + + /* ---- 3. a marble run that picks a tool ---- */ + SC.pick = { + dur: 14, still: 14, + build(svg, api) { + const END = 14, X = [112, 304, 496, 688], GY = [150, 280, 410], BY = 532, CH = 500, RESET = 7.6; + const MARBLE = { + s: [[112, 24], [112, 150]], + y0: [[112, 150], [112, BY]], n0: [[112, 150], [304, 150], [304, 280]], + y1: [[304, 280], [304, BY]], n1: [[304, 280], [496, 280], [496, 410]], + y2: [[496, 410], [496, BY]], n2: [[496, 410], [688, 410], [688, BY]], + }; + const D = { s: 0.55, y0: 0.95, y1: 0.75, y2: 0.5, n0: 0.95, n1: 0.95, n2: 0.95 }; + const IDS = ["s", "y0", "n0", "y1", "n1", "y2", "n2"]; + const chanPts = (id) => { const p = MARBLE[id].map((q) => q.slice()); if (id === "s") p[0][1] = 40; if (p[p.length - 1][1] === BY) p[p.length - 1][1] = CH; return p; }; + const inkL = el("g", {}, svg), whiteL = el("g", {}, svg), hlL = el("g", {}, svg); + const CHN = {}; + IDS.forEach((id) => { + const pts = chanPts(id), d = pd(pts), L = plen(pts); + const base = { d, fill: "none", "stroke-linejoin": "round" }; + CHN[id] = { + L, off: id === "s" ? 16 : 0, + ink: el("path", Object.assign({ stroke: INK, "stroke-width": 28 }, base), inkL), + white: el("path", Object.assign({ stroke: "#fff", "stroke-width": 22 }, base), whiteL), + hl: el("path", Object.assign({ stroke: YEL_SOFT, "stroke-width": 22 }, base), hlL), + }; + }); + /* hopper */ + const hop = el("g", {}, svg); + el("path", { d: "M-34 -18 H34 L16 14 H-16 Z", fill: YEL_SOFT, stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, hop); + /* bins */ + const NAMES = ["Claude Cowork", "Munder Difflin", "OpenClaw", "Hermes Agent"]; + const bins = NAMES.map((s, i) => { + const g = el("g", {}, svg), x = X[i]; + const halo = el("path", { d: "M-86 -38 V40 Q-86 56 -70 56 H70 Q86 56 86 40 V-38", fill: "none", stroke: MINT, "stroke-width": 10, "stroke-linejoin": "round", "stroke-linecap": "round", opacity: 0 }, g); + const cup = el("path", { d: "M-86 -38 V40 Q-86 56 -70 56 H70 Q86 56 86 40 V-38", fill: "#fff", stroke: INK, "stroke-width": 3, "stroke-linejoin": "round", "stroke-linecap": "round" }, g); + const lb = txt(g, 0, 40, s, { "text-anchor": "middle", "font-size": 18, "font-weight": 700 }); + const w = lb.getComputedTextLength(); if (w > 152) lb.setAttribute("font-size", (18 * 152) / w); + return { g, cup, halo, x }; + }); + /* gates, question cards and exit labels */ + const QS = ["Must it work with your laptop shut?", "Is your work a codebase?", "Want it in WhatsApp or Telegram?"]; + const cards = QS.map((q, i) => { + /* the first card wraps so it stays clear of the buttons */ + const g = el("g", {}, svg), x = X[i] + 32, maxW = i === 0 ? 236 : 800 - 24 - x - 32; + const box = el("rect", { x: 0, y: 0, rx: 12, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, g); + const lines = []; let ln = txt(g, 16, 29, "", { "font-size": 19, "font-weight": 700 }); lines.push(ln); + let w = 0; + q.split(" ").forEach((word) => { + const prev = ln.textContent; ln.textContent = prev ? prev + " " + word : word; + if (ln.getComputedTextLength() > maxW && prev) { ln.textContent = prev; ln = txt(g, 16, 29 + lines.length * 25, word, { "font-size": 19, "font-weight": 700 }); lines.push(ln); } + }); + lines.forEach((n) => (w = Math.max(w, n.getComputedTextLength()))); + const h = 44 + (lines.length - 1) * 25; + box.setAttribute("width", Math.ceil(w) + 32); box.setAttribute("height", h); + return { g, x, y: GY[i] - 22 - h }; + }); + const exits = []; + GY.forEach((y, i) => { + exits.push(txt(svg, X[i] - 24, y + 50, "Yes", { "text-anchor": "end", "font-size": 16, "font-weight": 700 })); + exits.push(txt(svg, X[i] + 36, y + 36, "No", { "font-size": 16, "font-weight": 700 })); + }); + const gates = GY.map((y, i) => { + const g = el("g", {}, svg); + const wait = el("circle", { r: 22, fill: "none", stroke: YEL, "stroke-width": 5, opacity: 0 }, g); + el("circle", { r: 22, fill: "#fff", stroke: INK, "stroke-width": 3 }, g); + const arm = el("rect", { x: -4, y: -4, width: 24, height: 8, rx: 4, fill: INK }, g); + el("circle", { r: 4, fill: YEL, stroke: INK, "stroke-width": 1.5 }, g); + return { g, wait, arm, x: X[i], y }; + }); + /* controls */ + const hintT = txt(svg, 768, 46, "Steer the marble", { "text-anchor": "end", "font-size": 16, fill: MUTE, "font-weight": 500 }); + const mkBtn = (label, aria) => { + const g = el("g", {}, svg), body = el("g", {}, g); + const r = el("rect", { y: -20, height: 40, rx: 20, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, body); + const t = txt(body, 0, 6.5, label, { "text-anchor": "middle", "font-size": 18, "font-weight": 700 }); + const w = Math.ceil(t.getComputedTextLength()) + 40; + r.setAttribute("x", -w / 2); r.setAttribute("width", w); + el("rect", { x: -w / 2 - 6, y: -26, width: w + 12, height: 52, rx: 26, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, body); + return { g, body, r, t, w, aria }; + }; + const bY = mkBtn("Yes"), bN = mkBtn("No"), bD = mkBtn("Drop again"); + { let x = 768; [bD, bN, bY].forEach((b) => { x -= b.w; b.x = x + b.w / 2; x -= 10; }); } + const ghost = el("circle", { r: 11, fill: YEL, stroke: INK, "stroke-width": 2.5, opacity: 0 }, svg); + const marble = el("g", {}, svg); + el("circle", { r: 11, fill: YEL, stroke: INK, "stroke-width": 2.5 }, marble); + el("circle", { cx: -3.5, cy: -3.5, r: 3, fill: "#fff" }, marble); + + /* where the marble is for a run, at clock c */ + const runState = (run, c) => { + const S = { pos: MARBLE.s[0], dist: {}, flip: [null, null, null], wait: -1, bin: -1, land: 0, chosen: [] }; + (run.preLegs || []).forEach((id) => (S.dist[id] = 1e4)); + (run.preFlip || []).forEach((f, g) => f && (S.flip[g] = { dir: f.dir, at: -1e9 })); + let g = run.from, cur = run.at; + if (run.drop) { + const k = seg(c, run.at, run.at + D.s), L = plen(MARBLE.s); + S.dist.s = L * k * k; S.pos = pat(MARBLE.s, S.dist.s); cur = run.at + D.s; + if (c < cur) return S; + } + S.pos = [X[g], GY[g]]; + for (const ch of run.choices) { + const tc = Math.max(ch.at !== undefined ? ch.at : -1e9, cur + (ch.wait || 0)); + if (c < tc) { S.wait = g; return S; } + S.flip[g] = { dir: ch.c, at: tc }; S.chosen.push({ c: ch.c, at: tc }); + const id = ch.c + g, dep = tc + 0.15, pts = MARBLE[id], L = plen(pts); + if (c < dep) return S; + const k = seg(c, dep, dep + D[id]), e = id[0] === "y" ? k * k : io(k); + S.dist[id] = L * e; S.pos = pat(pts, L * e); + if (k < 1) return S; + cur = dep + D[id]; + if (id[0] === "y" || g === 2) { S.bin = id[0] === "y" ? g : 3; S.land = cur; return S; } + g++; + S.pos = [X[g], GY[g]]; + } + S.wait = c >= cur ? g : -1; + return S; + }; + const W = 0.55; + const runA = { drop: true, from: 0, at: 1.6, choices: [{ c: "n", wait: W }, { c: "n", wait: W }, { c: "y", wait: W }] }; + const runB = { drop: true, from: 0, at: RESET + 0.85, choices: [{ c: "n", wait: W }, { c: "y", wait: W }] }; + let man = null, last = null, press = { y: -9, n: -9, d: -9 }, gh = null; + const choose = (c) => { + const now = api.now(); + /* in manual play work out the marble from the clock, not from the last painted frame */ + const S = man ? runState(man, api.calm ? Infinity : now) : last; + if (!S || S.wait < 0) return; + if (!man) man = { drop: false, from: S.wait, at: now, preLegs: Object.keys(S.dist).filter((k) => S.dist[k] > 0), preFlip: S.flip.slice(), choices: [] }; + man.choices.push({ c, at: now }); + press[c] = now; api.poke(); + }; + button(bY.g, "Yes", () => choose("y")); + button(bN.g, "No", () => choose("n")); + button(bD.g, "Drop again", () => { + const now = api.now(); + if (last) gh = { x: last.mx, y: last.my, at: now }; + man = { drop: true, from: 0, at: now + 0.3, choices: [] }; + press.d = now; api.poke(); + }); + + return (t, now) => { + const auto = !man, c = auto ? t : api.calm ? Infinity : now, ck = auto ? t : api.calm ? END : now; + const B = auto ? t : 99; + let S, out = 0, run; + if (auto) { run = t < RESET + 0.5 ? runA : runB; S = runState(run, t); if (run === runA) out = seg(t, RESET, RESET + 0.4); } + else { run = man; S = runState(man, c); } + + /* board build */ + IDS.forEach((id, i) => { + const k = oc(seg(B, 0.3 + i * 0.08, 0.85 + i * 0.08)), ch = CHN[id]; + dash(ch.ink, ch.L, k); dash(ch.white, ch.L, k); + const d = S.dist[id] || 0, vis = cl((d - ch.off) / ch.L); + dash(ch.hl, ch.L, vis); ch.hl.setAttribute("opacity", vis > 0 ? 1 - out : 0); + }); + tf(hop, 112, 24, ob(seg(B, 0.1, 0.5))); + bins.forEach((b, i) => { + const k = seg(B, 0.85 + i * 0.08, 1.35 + i * 0.08); + const glow = S.bin === i ? seg(c, S.land, S.land + 0.4) * (1 - out) : 0; + const p = bump(ck, END, 1.8, 0, 0.5) * glow; + tf(b.g, b.x, 532 - 20 * (1 - ob(k)), 1); b.g.setAttribute("opacity", seg(k, 0, 0.3)); + b.cup.setAttribute("fill", mix("#ffffff", MINT, glow)); + b.halo.setAttribute("opacity", glow * (0.35 + 0.4 * p)); b.halo.setAttribute("stroke-width", 8 + 8 * p); + }); + cards.forEach((cd, i) => { const k = seg(B, 0.7 + i * 0.12, 1.2 + i * 0.12); tf(cd.g, cd.x - 24 * (1 - ob(k)), cd.y); cd.g.setAttribute("opacity", seg(k, 0, 0.4)); }); + exits.forEach((n, i) => n.setAttribute("opacity", seg(B, 1.05 + i * 0.06, 1.35 + i * 0.06))); + gates.forEach((gt, i) => { + const k = ob(seg(B, 0.1 + i * 0.1, 0.5 + i * 0.1)); + tf(gt.g, gt.x, gt.y, k); + const fl = S.flip[i]; + let a = 45; + if (fl && !(auto && out >= 1)) { const target = fl.dir === "y" ? 90 : 0; a = 45 + (target - 45) * ob(seg(c, fl.at, fl.at + 0.35)); } + if (auto && out > 0 && fl) { const target = fl.dir === "y" ? 90 : 0; a = target + (45 - target) * io(out); } + gt.arm.setAttribute("transform", `rotate(${a})`); + const w = S.wait === i ? bump(ck, 0, 1.0, 0, 1) : 0; + gt.wait.setAttribute("r", 22 + 9 * w); gt.wait.setAttribute("opacity", S.wait === i ? 0.4 + 0.6 * (1 - w) : 0); + }); + hintT.setAttribute("opacity", seg(B, 1.2, 1.5)); + const live = S.wait >= 0; + const pressed = { y: -9, n: -9, d: -9 }; + if (auto) { S.chosen.forEach((ch) => (pressed[ch.c] = ch.at)); if (t >= RESET - 0.1) pressed.d = RESET - 0.1; } + else { pressed.y = press.y; pressed.n = press.n; pressed.d = press.d; } + [[bY, "y"], [bN, "n"], [bD, "d"]].forEach(([b, k], i) => { + const e = seg(B, 1.2 + i * 0.08, 1.6 + i * 0.08); + tf(b.g, b.x, 98 + 14 * (1 - ob(e))); b.g.setAttribute("opacity", seg(e, 0, 0.4)); + const pk = seg(auto ? t : now, pressed[k], pressed[k] + 0.3), dip = pk > 0 && pk < 1 ? Math.sin(Math.PI * pk) : 0; + b.body.setAttribute("transform", `translate(0 ${3 * dip}) scale(${1 - 0.05 * dip})`); + const on = k === "d" || live; + b.r.setAttribute("fill", k === "d" ? mix("#ffffff", LIL, 0.35 + 0.65 * dip) : on ? mix(YEL, "#ffffff", 0) : SOFT); + b.t.setAttribute("fill", on ? INK : MUTE); + if (k !== "d") b.g.setAttribute("aria-disabled", on ? "false" : "true"); + }); + /* marble */ + let [mx, my] = S.pos; + if (S.bin >= 0) { const k = seg(c, S.land, S.land + 0.55); my -= k > 0 && k < 1 ? 16 * Math.abs(Math.sin(k * Math.PI * 2)) * Math.pow(1 - k, 1.5) : 0; } + const app = ob(seg(c, run.drop ? run.at - 0.35 : -1e9, run.drop ? run.at : -1e9 + 1)); + const ms = run.drop ? app : 1; + tf(marble, mx, my, ms * (1 - 0.6 * out)); marble.setAttribute("opacity", (ms > 0 ? 1 : 0) * (1 - out)); + S.mx = mx; S.my = my; last = S; + if (gh && !auto) { const k = api.calm ? 1 : seg(now, gh.at, gh.at + 0.3); tf(ghost, gh.x, gh.y, 1 - 0.7 * k); ghost.setAttribute("opacity", k < 1 ? 1 - k : 0); } + else ghost.setAttribute("opacity", 0); + }; + }, + }; + + /* ---- player ---- */ + const q = new URLSearchParams(location.search), frozen = q.has("mgstill"), fixed = parseFloat(q.get("mgstill")); + const calm = matchMedia("(prefers-reduced-motion: reduce)").matches; + const mount = (fig) => { + const sc = SC[fig.dataset.scene], img = fig.querySelector("img"); if (!sc || !img) return; + const W = sc.w || 800, H = sc.h || 600; + const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "mg-svg", role: "group", "aria-label": img.alt || "" }); + svg.style.aspectRatio = `${W} / ${H}`; + el("rect", { width: W, height: H, fill: "#fff" }, svg); + img.replaceWith(svg); + let off = 0, raf = 0, seen = false, held = calm; + const base = performance.now(), clock = () => (performance.now() - base) / 1000; + let draw = () => {}; + const frame = () => { const now = clock(); draw(held ? sc.still : (((now + off) % sc.dur) + sc.dur) % sc.dur, now); }; + const tick = () => { cancelAnimationFrame(raf); frame(); if (seen && !held) raf = requestAnimationFrame(tick); }; + // with reduced motion a tap redraws the still frame with the new choice instead of starting playback + const api = { now: clock, calm, seek: (s) => { if (calm) return frame(); off = s - clock(); held = false; tick(); }, poke: () => { if (calm) return frame(); held = false; tick(); } }; + draw = sc.build(svg, api); + if (frozen) { draw(isNaN(fixed) ? sc.still : fixed, 0); return; } + new IntersectionObserver(([e]) => { + seen = e.isIntersecting; + if (seen) { tick(); if (!fig.dataset.sent && window.posthog) { fig.dataset.sent = 1; window.posthog.capture("blog_scene_view", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } } + else cancelAnimationFrame(raf); + }, { threshold: 0.25 }).observe(fig); + svg.addEventListener("click", () => { if (!fig.dataset.hit && window.posthog) { fig.dataset.hit = 1; window.posthog.capture("blog_scene_interact", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } }); + frame(); + }; + const go = () => document.querySelectorAll("figure.mg[data-scene]").forEach(mount); + const fonts = document.fonts && document.fonts.load ? Promise.all([document.fonts.load('700 32px "Space Grotesk"'), document.fonts.load('500 20px "Space Grotesk"'), document.fonts.load('700 20px "JetBrains Mono"')]).catch(() => {}) : Promise.resolve(); + const start = () => fonts.then(go, go); + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); +})(); diff --git a/blog/src/assets/media/claude-duet-alternatives/pick.png b/blog/src/assets/media/claude-duet-alternatives/pick.png new file mode 100644 index 0000000000..c6b34f2278 Binary files /dev/null and b/blog/src/assets/media/claude-duet-alternatives/pick.png differ diff --git a/blog/src/assets/media/claude-duet-alternatives/start.png b/blog/src/assets/media/claude-duet-alternatives/start.png new file mode 100644 index 0000000000..05b92098e4 Binary files /dev/null and b/blog/src/assets/media/claude-duet-alternatives/start.png differ diff --git a/blog/src/assets/media/claude-duet-alternatives/where.png b/blog/src/assets/media/claude-duet-alternatives/where.png new file mode 100644 index 0000000000..d8d09119d4 Binary files /dev/null and b/blog/src/assets/media/claude-duet-alternatives/where.png differ diff --git a/blog/src/assets/media/claude-duet-for-developers/day.png b/blog/src/assets/media/claude-duet-for-developers/day.png new file mode 100644 index 0000000000..62a1ef9dfb Binary files /dev/null and b/blog/src/assets/media/claude-duet-for-developers/day.png differ diff --git a/blog/src/assets/media/claude-duet-for-developers/hero.png b/blog/src/assets/media/claude-duet-for-developers/hero.png new file mode 100644 index 0000000000..66d4064242 Binary files /dev/null and b/blog/src/assets/media/claude-duet-for-developers/hero.png differ diff --git a/blog/src/assets/media/claude-duet-for-developers/machines.png b/blog/src/assets/media/claude-duet-for-developers/machines.png new file mode 100644 index 0000000000..723032877a Binary files /dev/null and b/blog/src/assets/media/claude-duet-for-developers/machines.png differ diff --git a/blog/src/assets/media/claude-duet-for-developers/motion.css b/blog/src/assets/media/claude-duet-for-developers/motion.css new file mode 100644 index 0000000000..34b29f63d1 --- /dev/null +++ b/blog/src/assets/media/claude-duet-for-developers/motion.css @@ -0,0 +1,13 @@ +/* Bright coded scenes for "Claude Duet for developers". Pairs with motion.js. */ +.prose figure.mg { margin: 42px 0; } +.prose figure.mg img, .mg .mg-svg { display: block; width: 100%; height: auto; border: 1px solid #1A1320; border-radius: 18px; background: #fff; } +.mg figcaption { font: 500 13px/1.5 "Space Grotesk", system-ui, sans-serif; color: #6B5878; padding-top: 10px; text-align: center; } +.mg .mg-svg { touch-action: manipulation; -webkit-tap-highlight-color: transparent; } +.mg .mg-svg text { -webkit-user-select: none; user-select: none; pointer-events: none; } +.mg .mg-hit { cursor: pointer; outline: none; } +.mg [role="slider"] { cursor: grab; touch-action: none; } +.mg .mg-ring { stroke-opacity: 0; transition: stroke-opacity 0.15s; } +.mg .mg-hit:focus-visible .mg-ring { stroke-opacity: 1; } +.mg [role="slider"]:focus-visible circle:nth-child(2) { stroke-width: 5; } +@media (hover: hover) { .mg .mg-hit:hover .mg-ring { stroke-opacity: 0.5; } } +@media (prefers-reduced-motion: reduce) { .mg .mg-ring { transition: none; } } diff --git a/blog/src/assets/media/claude-duet-for-developers/motion.js b/blog/src/assets/media/claude-duet-for-developers/motion.js new file mode 100644 index 0000000000..321eaa3b11 --- /dev/null +++ b/blog/src/assets/media/claude-duet-for-developers/motion.js @@ -0,0 +1,658 @@ +/* Bright coded scenes for "Claude Duet for developers". Vanilla JS, no library. +
holds a still image; this script swaps in a live SVG. + Plays only in view, honours reduced motion, "?mgstill" freezes each scene on its still frame. */ +(() => { + const NS = "http://www.w3.org/2000/svg"; + const INK = "#1A1320", YEL = "#FFCA54", BLUE = "#6C8EF5", LIL = "#B69CFF", MINT = "#7FD8AE", SKY = "#9AD8FF", SOFT = "#F1ECF9", MUTE = "#7A6A88"; + const SKY_SOFT = "#DDF1FF", LIL_SOFT = "#E6DCFF", GREY = "#CAC3D2"; + const SANS = '"Space Grotesk", system-ui, sans-serif', MONO = '"JetBrains Mono", ui-monospace, monospace'; + const el = (t, a = {}, p) => { const n = document.createElementNS(NS, t); for (const k in a) n.setAttribute(k, a[k]); if (p) p.appendChild(n); return n; }; + const txt = (p, x, y, s, a = {}) => { const n = el("text", Object.assign({ x, y, "font-family": SANS, "font-size": 24, "font-weight": 600, fill: INK }, a), p); n.textContent = s; return n; }; + const mono = (p, x, y, s, a = {}) => txt(p, x, y, s, Object.assign({ "font-family": MONO, "font-weight": 700 }, a)); + const fit = (n, max) => { const w = n.getComputedTextLength(); if (w > max) n.setAttribute("font-size", (+n.getAttribute("font-size") * max) / w); return n; }; + const cl = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v)); + const seg = (t, a, b) => cl((t - a) / (b - a)); + const oc = (x) => 1 - Math.pow(1 - x, 3); + /* back out with an adjustable overshoot: c 1.7 is a lively arrival, c 0.8 a soft one */ + const bk = (x, c = 1.70158) => 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); + const ob = (x) => bk(x); + const io = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); + const dip = (k) => (k > 0 && k < 1 ? Math.sin(Math.PI * k) : 0); + /* a squash and stretch wobble after a landing: k runs 0 to 1 */ + const squash = (k) => (k > 0 && k < 1 ? Math.sin(k * Math.PI * 2) * (1 - k) * 0.14 : 0); + /* a soft idle beat: 0 at rest, a smooth hump for the first w of each period P; always at rest when t = end (for d < P * (1 - w)) */ + const bump = (t, end, P, d = 0, w = 0.5) => { if (!isFinite(t)) return 0; const u = ((((t - end - d) / P) % 1) + 1) % 1; return u < w ? Math.sin((Math.PI * u) / w) : 0; }; + /* a cursor blink that is on at t = end */ + const blink = (t, end, P = 0.9) => ((((t - end) / P) % 1) + 1) % 1 < 0.55; + const tf = (n, x, y, s = 1, r = 0) => n.setAttribute("transform", `translate(${x} ${y}) rotate(${r}) scale(${s})`); + const hex = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + const mix = (a, b, k) => { const A = hex(a), B = hex(b); return "#" + A.map((v, i) => Math.round(v + (B[i] - v) * cl(k)).toString(16).padStart(2, "0")).join(""); }; + const rise = (n, k, d = 16) => { n.setAttribute("transform", `translate(0 ${d * (1 - oc(k))})`); n.setAttribute("opacity", seg(k, 0, 0.4)); }; + const YEL_T = mix(YEL, "#ffffff", 0.45), BLUE_T = mix(BLUE, "#ffffff", 0.68), MINT_T = mix(MINT, "#ffffff", 0.2); + const button = (g, label, fn) => { + g.setAttribute("role", "button"); g.setAttribute("tabindex", "0"); g.setAttribute("aria-label", label); g.classList.add("mg-hit"); + g.addEventListener("click", fn); + g.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); } }); + }; + const hint = (svg, s, x = 756, y = 50) => txt(svg, x, y, s, { "text-anchor": "end", "font-size": 16, fill: MUTE, "font-weight": 500 }); + /* a small window body: white box, coloured title band, three dots, optional label in the band */ + const winFrame = (p, x0, y0, w, h, band, label, r = 12, bh = 30) => { + el("rect", { x: x0, y: y0, width: w, height: h, rx: r, fill: "#fff" }, p); + el("path", { d: `M${x0} ${y0 + bh} V${y0 + r} A${r} ${r} 0 0 1 ${x0 + r} ${y0} H${x0 + w - r} A${r} ${r} 0 0 1 ${x0 + w} ${y0 + r} V${y0 + bh} Z`, fill: band }, p); + el("path", { d: `M${x0} ${y0 + bh} H${x0 + w}`, stroke: INK, "stroke-width": 2.5 }, p); + [0, 1, 2].forEach((i) => el("circle", { cx: x0 + 16 + i * 12, cy: y0 + bh / 2, r: 3.6, fill: "#fff", stroke: INK, "stroke-width": 1.8 }, p)); + if (label) fit(txt(p, x0 + 58, y0 + bh / 2 + 6, label, { "font-size": 17, "font-weight": 700 }), w - 70); + el("rect", { x: x0, y: y0, width: w, height: h, rx: r, fill: "none", stroke: INK, "stroke-width": 3 }, p); + }; + /* scale a node about a point */ + const about = (n, x, y, sx, sy = sx, r = 0) => n.setAttribute("transform", `translate(${x} ${y}) rotate(${r}) scale(${sx} ${sy}) translate(${-x} ${-y})`); + + const SC = {}; + + /* ---- title card, 16 by 9: a balance scale with an inbox tray and a code block that settles level ---- */ + SC.hero = { + w: 960, h: 540, dur: 10, still: 10, + build(svg) { + const END = 10, CX = 742, PY = 198, L = 130, HANG = 100, GY = 478; + const pill = el("g", {}, svg); + const pr = el("rect", { y: -20, height: 40, rx: 20, fill: YEL, stroke: INK, "stroke-width": 2.5 }, pill); + const pt = txt(pill, 0, 7, "Developers", { "text-anchor": "middle", "font-size": 19, "font-weight": 700 }); + const pw = Math.ceil(pt.getComputedTextLength()) + 40; + pr.setAttribute("x", -pw / 2); pr.setAttribute("width", pw); + const l1g = el("g", {}, svg), l2g = el("g", {}, svg); + const l1 = txt(l1g, 60, 0, "Is Claude Duet good", { "font-size": 60, "font-weight": 700 }); + const l2 = txt(l2g, 60, 0, "for developers?", { "font-size": 60, "font-weight": 700 }); + const TW = 446, tmax = Math.max(l1.getComputedTextLength(), l2.getComputedTextLength()); + if (tmax > TW) [l1, l2].forEach((n) => n.setAttribute("font-size", (60 * TW) / tmax)); + const FS = +l1.getAttribute("font-size"), Y1 = 196, Y2 = Math.round(Y1 + FS * 1.17); + l1.setAttribute("y", Y1); l2.setAttribute("y", Y2); + const UL = Math.round(l2.getComputedTextLength() * 0.58), UY = Y2 + 22; + const under = el("path", { d: `M66 ${UY} H${66 + UL}`, stroke: YEL, "stroke-width": 13, "stroke-linecap": "round", fill: "none" }, svg); + const s1g = el("g", {}, svg), s2g = el("g", {}, svg); + const s1 = txt(s1g, 63, UY + 54, "What it does with code,", { "font-size": 26, "font-weight": 500, fill: MUTE }); + const s2 = txt(s2g, 63, UY + 88, "and what it doesn't", { "font-size": 26, "font-weight": 500, fill: MUTE }); + const smax = Math.max(s1.getComputedTextLength(), s2.getComputedTextLength()); + if (smax > 420) [s1, s2].forEach((n) => n.setAttribute("font-size", (26 * 420) / smax)); + const chkG = el("g", {}, svg); + txt(chkG, 64, 470, "Checked 22 Oct 2026", { "font-size": 18, "font-weight": 500, fill: MUTE }); + + /* ground, foot and post */ + const gr = el("g", {}, svg); + const g1 = el("path", { d: `M576 ${GY} H908`, stroke: SOFT, "stroke-width": 16, "stroke-linecap": "round", fill: "none" }, gr); + const g2 = el("path", { d: `M576 ${GY} H908`, stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round", fill: "none" }, gr); + const foot = el("g", {}, svg); + el("path", { d: `M${CX - 74} ${GY} L${CX - 52} ${GY - 24} H${CX + 52} L${CX + 74} ${GY} Z`, fill: LIL, stroke: INK, "stroke-width": 3, "stroke-linejoin": "round" }, foot); + const post = el("g", {}, svg); + el("rect", { x: CX - 9, y: PY - 30, width: 18, height: GY - 24 - PY + 32, rx: 6, fill: "#fff", stroke: INK, "stroke-width": 3 }, post); + const fin = el("g", {}, svg); + el("circle", { r: 10, fill: MINT, stroke: INK, "stroke-width": 3 }, fin); + /* the gauge under the pivot */ + const gauge = el("g", {}, svg); + el("path", { d: `M${CX - 38} ${PY + 4} A38 38 0 0 0 ${CX + 38} ${PY + 4} Z`, fill: SOFT, stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, gauge); + [-30, 0, 30].forEach((a) => { const r = (a + 90) * Math.PI / 180; el("path", { d: `M${CX + Math.cos(r) * 27} ${PY + 4 + Math.sin(r) * 27} L${CX + Math.cos(r) * 34} ${PY + 4 + Math.sin(r) * 34}`, stroke: a ? INK : MINT, "stroke-width": a ? 2.5 : 4, "stroke-linecap": "round" }, gauge); }); + const needle = el("path", { d: "M-3.5 0 L0 30 L3.5 0 Z", fill: INK, "stroke-linejoin": "round" }, svg); + + /* the pans hang from the beam ends and stay upright */ + const mkPan = () => { + const g = el("g", {}, svg), sw = el("g", {}, g); + el("path", { d: `M0 0 L-54 ${HANG} M0 0 L54 ${HANG}`, stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round", fill: "none" }, sw); + const item = el("g", {}, sw); + el("path", { d: `M-64 ${HANG} H64 Q52 ${HANG + 28} 0 ${HANG + 28} Q-52 ${HANG + 28} -64 ${HANG} Z`, fill: SKY, stroke: INK, "stroke-width": 3, "stroke-linejoin": "round" }, sw); + el("circle", { r: 6, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, sw); + return { g, sw, item }; + }; + const beam = el("g", {}, svg); + el("rect", { x: -L - 12, y: -8, width: 2 * L + 24, height: 16, rx: 8, fill: YEL, stroke: INK, "stroke-width": 3 }, beam); + const pans = [mkPan(), mkPan()]; + const cap = el("g", {}, svg); + el("circle", { r: 14, fill: BLUE, stroke: INK, "stroke-width": 3 }, cap); + el("circle", { r: 4, fill: "#fff", stroke: INK, "stroke-width": 2 }, cap); + + /* inbox tray: back plate, two sheets, a notched front */ + const tray = el("g", {}, pans[0].item), trayIn = el("g", {}, tray); + el("rect", { x: -44, y: -44, width: 88, height: 18, rx: 5, fill: YEL_T, stroke: INK, "stroke-width": 2.5 }, trayIn); + [[-12, -7], [12, 6]].forEach(([x, r]) => { + const s = el("g", { transform: `translate(${x} -32) rotate(${r})` }, trayIn); + el("rect", { x: -20, y: -18, width: 40, height: 28, rx: 4, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, s); + el("rect", { x: -12, y: -10, width: 24, height: 5, rx: 2.5, fill: GREY }, s); + }); + el("path", { d: "M-48 -30 H-20 L-14 -18 H14 L20 -30 H48 V-5 Q48 2 41 2 H-41 Q-48 2 -48 -5 Z", fill: YEL, stroke: INK, "stroke-width": 3, "stroke-linejoin": "round" }, trayIn); + /* code block */ + const code = el("g", {}, pans[1].item), codeIn = el("g", {}, code); + el("rect", { x: -46, y: -56, width: 92, height: 58, rx: 12, fill: BLUE, stroke: INK, "stroke-width": 3 }, codeIn); + mono(codeIn, 0, -16, "{ }", { "text-anchor": "middle", "font-size": 30, "font-weight": 700 }); + + /* beam angle: tray lands, it tips left; the block lands, it swings right; then it settles and sways */ + const ang = (t) => { + let a = -12 * bk(seg(t, 2.55, 3.35), 1.4); + if (t >= 4.55) a = -12 + 22 * io(seg(t, 4.55, 5.45)); + if (t >= 5.45) { const u = seg(t, 5.45, 8.2); a = 10 * (1 - u) * (1 - u) * Math.cos(Math.PI * 2.5 * u); } + return a + 0.9 * Math.sin((2 * Math.PI * (t - END)) / 3.6) * seg(t, 8.0, 8.8); + }; + /* an item pops in just above its pan, then drops in under gravity and squashes */ + const drop = (n, inner, t, a) => { + const p = seg(t, a - 0.22, a), f = seg(t, a, a + 0.36), y = -36 * (1 - f * f); + n.setAttribute("transform", `translate(0 ${HANG + 6 + y}) scale(${0.5 + 0.5 * bk(p, 2)})`); n.setAttribute("opacity", seg(p, 0, 0.4)); + const q = squash(seg(t, a + 0.36, a + 0.8)); + inner.setAttribute("transform", `scale(${1 + q} ${1 - q})`); + }; + + return (t) => { + const a = ob(seg(t, 0.15, 0.6)); + tf(pill, 64 + pw / 2, 96, 0.6 + 0.4 * a); pill.setAttribute("opacity", seg(t, 0.15, 0.3)); + [l1g, l2g].forEach((g, i) => rise(g, seg(t, 0.3 + i * 0.1, 0.9 + i * 0.1), 28)); + const ul = oc(seg(t, 0.95, 1.5)); under.setAttribute("stroke-dasharray", `${UL} ${UL + 20}`); under.setAttribute("stroke-dashoffset", UL * (1 - ul)); under.setAttribute("opacity", ul > 0 ? 1 : 0); + [s1g, s2g].forEach((g, i) => rise(g, seg(t, 0.62 + i * 0.09, 1.2 + i * 0.09), 16)); + rise(chkG, seg(t, 0.95, 1.4), 10); + + const hk = oc(seg(t, 0.45, 1.1)); + [g1, g2].forEach((n) => { n.setAttribute("stroke-dasharray", "340 360"); n.setAttribute("stroke-dashoffset", 340 * (1 - hk)); }); + gr.setAttribute("opacity", hk > 0 ? 1 : 0); + const fk = seg(t, 0.6, 1.0); about(foot, CX, GY, 0.5 + 0.5 * ob(fk), 0.3 + 0.7 * ob(fk)); foot.setAttribute("opacity", seg(fk, 0, 0.25)); + const pk = seg(t, 0.8, 1.25); about(post, CX, GY - 24, 1, Math.max(0.02, oc(pk))); post.setAttribute("opacity", pk > 0 ? 1 : 0); + const nk = seg(t, 1.12, 1.45); tf(fin, CX, PY - 34, ob(nk)); fin.setAttribute("opacity", nk > 0 ? 1 : 0); + const gk = seg(t, 1.3, 1.65); about(gauge, CX, PY, ob(gk)); gauge.setAttribute("opacity", gk > 0 ? 1 : 0); + + const th = ang(t), rad = (th * Math.PI) / 180; + const bkk = seg(t, 1.2, 1.7); + beam.setAttribute("transform", `translate(${CX} ${PY}) rotate(${th}) scale(${Math.max(0.01, bk(bkk, 1.4))} 1)`); + beam.setAttribute("opacity", bkk > 0 ? 1 : 0); + tf(needle, CX, PY, 1, th); needle.setAttribute("opacity", seg(t, 1.55, 1.75)); + const ck = seg(t, 1.32, 1.65); tf(cap, CX, PY, ob(ck)); cap.setAttribute("opacity", ck > 0 ? 1 : 0); + + const vel = (ang(t + 0.03) - ang(t - 0.03)) / 0.06; + pans.forEach((P, i) => { + const s = i ? 1 : -1, x = CX + s * L * Math.cos(rad), y = PY + s * L * Math.sin(rad); + const k = seg(t, 1.55 + i * 0.1, 2.0 + i * 0.1); + tf(P.g, x, y - 26 * (1 - ob(k)), 1, 0); + P.g.setAttribute("opacity", seg(k, 0, 0.25)); + P.sw.setAttribute("transform", `rotate(${cl(-0.14 * vel, -7, 7)})`); + }); + drop(tray, trayIn, t, 2.2); + drop(code, codeIn, t, 4.2); + }; + }, + }; + + /* ---- 1. two overlapping circles: business apps, your repo, and the shared middle ---- */ + SC.venn = { + dur: 12, still: 12, + build(svg, api) { + const END = 12, CY = 322, R = 205, LX = 290, RX = 510, H = Math.sqrt(R * R - 110 * 110), READY = 4.4; + const hintT = hint(svg, "Tap a chip", 756, 46); + const fillL = el("circle", { cx: LX, cy: CY, r: R, fill: YEL_T }, svg); + const fillR = el("circle", { cx: RX, cy: CY, r: R, fill: BLUE_T }, svg); + const lens = el("path", { d: `M400 ${CY - H} A${R} ${R} 0 0 1 400 ${CY + H} A${R} ${R} 0 0 1 400 ${CY - H} Z`, fill: MINT_T }, svg); + const C = 2 * Math.PI * R; + const ringL = el("circle", { cx: LX, cy: CY, r: R, fill: "none", stroke: INK, "stroke-width": 3, "stroke-dasharray": `${C} ${C}`, transform: `translate(${LX} ${CY}) scale(-1 1) rotate(-90) translate(${-LX} ${-CY})` }, svg); + const ringR = el("circle", { cx: RX, cy: CY, r: R, fill: "none", stroke: INK, "stroke-width": 3, "stroke-dasharray": `${C} ${C}`, transform: `rotate(-90 ${RX} ${CY})` }, svg); + + /* circle labels with a swatch */ + const label = (x, name, rest, sw, right) => { + const g = el("g", {}, svg); + const t = txt(g, 0, 89, name, { "font-size": 21, "font-weight": 700 }); + el("tspan", { "font-weight": 500 }, t).textContent = rest; + fit(t, 300); + const w = t.getComputedTextLength(), x0 = right ? x - w : x + 24; + t.setAttribute("x", x0); + el("circle", { cx: x0 - 16, cy: 82, r: 9, fill: sw, stroke: INK, "stroke-width": 2.5 }, g); + return g; + }; + const labL = label(44, "Claude Duet:", " business apps", YEL_T, false); + const labR = label(756, "Claude Code:", " your repo", BLUE_T, true); + const lensG = el("g", {}, svg); + [["Prototypes", -102], ["and web apps", -79]].forEach(([s, dy]) => { + const top = dy - 13, room = 2 * (Math.sqrt(R * R - top * top) - 110) - 18; + fit(txt(lensG, 400, CY + dy, s, { "text-anchor": "middle", "font-size": 17, "font-weight": 700 }), room); + }); + + /* chips */ + const GC = [YEL, BLUE, MINT]; + const NOTE = ["From Anthropic's Claude Duet examples", "From Anthropic's Claude Code page", "Both list it"]; + const mid = (dy, side) => 400 + side * Math.sqrt(R * R - dy * dy); + const SPEC = [ + ["Gmail", 0, -138, 10], ["QuickBooks", 0, -83, -8], ["Gusto", 0, -28, 12], ["Docusign", 0, 28, -6], ["HubSpot", 0, 83, 10], ["Shopify", 0, 138, -4], + ["Bug fix", 1, -105, -8], ["Tests", 1, -35, 10], ["Migration", 1, 35, -10], ["Code review", 1, 105, 4], + ["Prototype", 2, -14, 0], ["Web app", 2, 48, 0], + ]; + const chipL = el("g", {}, svg); + let pinned = -1, pinAt = -99, lastT = 0; + const COUNT = [0, 0, 0]; + const chips = SPEC.map(([s, grp, dy, zig], i) => { + const g = el("g", {}, chipL), inner = el("g", {}, g); + const wave = el("rect", { y: -17, height: 34, rx: 17, fill: "none", stroke: GC[grp], "stroke-width": 4, opacity: 0, "vector-effect": "non-scaling-stroke" }, inner); + const glow = el("rect", { y: -22.5, height: 45, rx: 22.5, fill: "#fff", stroke: INK, "stroke-width": 2.5, opacity: 0 }, inner); + const r = el("rect", { y: -17, height: 34, rx: 17, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, inner); + const t = txt(inner, 0, 6, s, { "text-anchor": "middle", "font-size": 17, "font-weight": 700 }); + const w = Math.ceil(t.getComputedTextLength()) + 32; + [r, wave].forEach((n) => { n.setAttribute("x", -w / 2); n.setAttribute("width", w); }); + glow.setAttribute("x", -w / 2 - 5.5); glow.setAttribute("width", w + 11); + el("rect", { x: -w / 2 - 10, y: -27, width: w + 20, height: 54, rx: 27, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, inner); + const n = COUNT[grp]++; + const x = grp === 2 ? 400 : mid(dy, grp === 0 ? -1 : 1) + zig, y = CY + dy; + const at = [1.5, 2.3, 3.05][grp] + n * (grp === 2 ? 0.14 : 0.1); + const sx = grp === 0 ? w / 2 + 10 : grp === 1 ? 790 - w / 2 : x, sy = grp === 2 ? 556 : y; + const pick = () => { + const now = api.now(); + if (pinned === i && now - pinAt < 0.3) return; + pinned = i; pinAt = now; + if (lastT < READY) api.seek(READY); else api.poke(); + }; + button(g, `${s}: ${NOTE[grp]}`, pick); + g.addEventListener("focus", pick); + return { g, inner, r, wave, glow, grp, x, y, sx, sy, at, w, rot: grp === 0 ? -14 : grp === 1 ? 14 : 0, i }; + }); + const AUTO = [[5.0, 0], [7.2, 7], [9.4, 10]]; + + /* the note under the diagram */ + const note = el("g", {}, svg), noteIn = el("g", {}, note); + const nBox = el("rect", { y: -22, height: 44, rx: 22, fill: SOFT, stroke: INK, "stroke-width": 2 }, noteIn); + const nDot = el("circle", { r: 8, fill: YEL, stroke: INK, "stroke-width": 2 }, noteIn); + const nTxt = txt(noteIn, 0, 7, "", { "font-size": 19, "font-weight": 600 }); + const NW = NOTE.map((s) => { nTxt.textContent = s; return Math.ceil(nTxt.getComputedTextLength()); }); + let shown = -1; + + return (t, now) => { + if (t < lastT - 0.5) pinned = -1; + lastT = t; + const calm = api.calm; + /* circles draw, then fill */ + [ringL, ringR].forEach((n, i) => { const k = io(seg(t, 0.15 + i * 0.12, 1.15 + i * 0.12)); n.setAttribute("stroke-dashoffset", C * (1 - k)); n.setAttribute("opacity", k > 0 ? 1 : 0); }); + [[fillL, LX], [fillR, RX]].forEach(([n, x], i) => { const k = seg(t, 0.45 + i * 0.12, 1.1 + i * 0.12); about(n, x, CY, 0.86 + 0.14 * ob(k)); n.setAttribute("opacity", seg(k, 0, 0.4)); }); + const lk = seg(t, 1.0, 1.45); about(lens, 400, CY, 0.4 + 0.6 * ob(lk), 0.7 + 0.3 * ob(lk)); lens.setAttribute("opacity", seg(lk, 0, 0.3)); + rise(labL, seg(t, 0.6, 1.1), 14); rise(labR, seg(t, 0.72, 1.22), 14); + rise(lensG, seg(t, 1.3, 1.75), 10); + hintT.setAttribute("opacity", seg(t, READY, READY + 0.4)); + + /* which chip is lit, and since when */ + let sel = -1, since = 0, live = false; + if (pinned >= 0) { sel = pinned; since = calm ? 9 : now - pinAt; live = true; } + else AUTO.forEach(([a, i]) => { if (t >= a) { sel = i; since = t - a; } }); + chips.forEach((c) => { + const k = seg(t, c.at, c.at + 0.85), e = bk(k, 1.25); + const x = c.sx + (c.x - c.sx) * e, y = c.sy + (c.y - c.sy) * e - 12 * Math.sin(Math.PI * oc(k)) * (c.grp === 2 ? 0 : 1); + const bob = -2.5 * bump(t, END, 2.8, (c.i * 0.23) % 1.3, 0.5) * seg(t, READY, READY + 0.5); + const on = c.i === sel, p = on ? dip(seg(since, 0, 0.45)) : 0; + const lit0 = on ? (calm ? 1 : oc(seg(since, 0, 0.3))) : 0; + tf(c.g, x, y + bob, 1 + 0.14 * p + 0.06 * lit0, c.rot * (1 - bk(k, 2.2))); + c.g.setAttribute("opacity", seg(k, 0, 0.3)); + const q = squash(seg(t, c.at + 0.85, c.at + 1.25)); + c.inner.setAttribute("transform", `scale(${1 + q} ${1 - q})`); + const lit = on ? (calm ? 1 : seg(since, 0, 0.25)) : 0; + c.r.setAttribute("fill", mix("#ffffff", GC[c.grp], lit)); + c.glow.setAttribute("opacity", lit); + /* a wave ring every 1.6 s while lit, at rest at the end of the loop */ + let wu = 2; + if (on && !calm) wu = live ? (since % 1.6) / 0.9 : (((((t - END - 0.6) / 1.6) % 1) + 1) % 1) / 0.55; + const wv = wu < 1 && (live || since > 0.3) ? Math.sin(Math.PI * wu) : 0; + c.wave.setAttribute("opacity", 0.9 * wv); + c.wave.setAttribute("transform", `scale(${1 + 0.22 * cl(wu)} ${1 + 0.6 * cl(wu)})`); + }); + + if (sel >= 0) { + const g = chips[sel].grp; + if (g !== shown) { shown = g; nTxt.textContent = NOTE[g]; const w = NW[g] + 62; nBox.setAttribute("x", -w / 2); nBox.setAttribute("width", w); nDot.setAttribute("cx", -w / 2 + 24); nTxt.setAttribute("x", -w / 2 + 42); nDot.setAttribute("fill", GC[g]); } + } + const nk = sel >= 0 ? (calm ? 1 : seg(since, 0.05, 0.4)) : 0; + tf(note, 400, 566 + 10 * (1 - oc(nk))); note.setAttribute("opacity", seg(nk, 0, 0.6)); + }; + }, + }; + + /* ---- 2. two machines: Duet's own virtual machine with a browser, and the places Claude Code runs ---- */ + SC.machines = { + dur: 12, still: 12, + build(svg) { + const END = 12; + /* left: the cloud, a dotted link, the virtual machine */ + const cloudG = el("g", {}, svg), cS = el("g", {}, cloudG), cF = el("g", {}, cloudG); + const SH = [["r", 140, 78, 140, 44, 22], ["c", 178, 80, 28], ["c", 218, 66, 36], ["c", 254, 86, 22]]; + const cloud = SH.map((s, i) => { + const mk = (p, a) => s[0] === "r" ? el("rect", Object.assign({ x: s[1], y: s[2], width: s[3], height: s[4], rx: s[5] }, a), p) : el("circle", Object.assign({ cx: s[1], cy: s[2], r: s[3] }, a), p); + return { a: mk(cS, { fill: INK, stroke: INK, "stroke-width": 6 }), b: mk(cF, { fill: SKY_SOFT }), cx: s[0] === "r" ? s[1] + s[3] / 2 : s[1], cy: s[0] === "r" ? s[2] + s[4] / 2 : s[2], i }; + }); + const link = [130, 140].map((y) => el("circle", { cx: 210, cy: y, r: 3, fill: INK }, svg)); + const box = el("g", {}, svg); + el("rect", { x: 40, y: 150, width: 340, height: 406, rx: 26, fill: SOFT, stroke: INK, "stroke-width": 3 }, box); + const labL = el("g", {}, svg); + fit(txt(labL, 64, 192, "Claude Duet's virtual machine", { "font-size": 21, "font-weight": 700 }), 292); + + /* the browser: tabs, address bar, two pages of content */ + const BX = 64, BY = 244, BW = 292, BH = 288; + const browser = el("g", {}, svg); + const TABS = [76, 162, 248].map((x, i) => { + const g = el("g", {}, browser); + const r = el("path", { d: `M${x} ${BY + 2} V${BY - 20} Q${x} ${BY - 30} ${x + 10} ${BY - 30} H${x + 70} Q${x + 80} ${BY - 30} ${x + 80} ${BY - 20} V${BY + 2}`, fill: LIL_SOFT, stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, g); + el("circle", { cx: x + 16, cy: BY - 14, r: 5, fill: [BLUE, MINT, YEL][i], stroke: INK, "stroke-width": 1.8 }, g); + el("rect", { x: x + 28, y: BY - 17, width: 40, height: 6, rx: 3, fill: GREY }, g); + return { g, r, x }; + }); + el("rect", { x: BX, y: BY, width: BW, height: BH, rx: 14, fill: "#fff", stroke: INK, "stroke-width": 3 }, browser); + const cover = el("rect", { x: 78.5, y: BY - 2, width: 77, height: 6, fill: "#fff" }, browser); + const addr = el("g", {}, browser); + el("rect", { x: BX + 16, y: BY + 14, width: BW - 32, height: 28, rx: 14, fill: SOFT, stroke: INK, "stroke-width": 2 }, addr); + el("circle", { cx: BX + 32, cy: BY + 28, r: 5, fill: "#fff", stroke: INK, "stroke-width": 1.8 }, addr); + const url = el("rect", { x: BX + 46, y: BY + 25, width: 0, height: 6, rx: 3, fill: MUTE }, addr); + const pageA = el("g", {}, browser), pageB = el("g", {}, browser); + const A = [ + el("rect", { x: BX + 16, y: BY + 56, width: BW - 32, height: 40, rx: 10, fill: LIL, stroke: INK, "stroke-width": 2.5 }, pageA), + (() => { const g = el("g", {}, pageA); el("rect", { x: BX + 16, y: BY + 108, width: 118, height: 92, rx: 10, fill: SKY, stroke: INK, "stroke-width": 2.5 }, g); el("circle", { cx: BX + 50, cy: BY + 136, r: 10, fill: YEL, stroke: INK, "stroke-width": 2 }, g); el("path", { d: `M${BX + 22} ${BY + 194} L${BX + 62} ${BY + 158} L${BX + 88} ${BY + 180} L${BX + 104} ${BY + 168} L${BX + 128} ${BY + 194} Z`, fill: MINT, stroke: INK, "stroke-width": 2, "stroke-linejoin": "round" }, g); return g; })(), + ...[0, 1, 2, 3].map((j) => el("rect", { x: BX + 148, y: BY + 114 + j * 22, width: [124, 96, 112, 70][j], height: 8, rx: 4, fill: GREY }, pageA)), + ...[0, 1, 2].map((j) => el("rect", { x: BX + 16 + j * 90, y: BY + 214, width: 80, height: 56, rx: 10, fill: [MINT, YEL, SOFT][j], stroke: INK, "stroke-width": 2.5 }, pageA)), + ]; + const B = [0, 1, 2, 3, 4].map((j) => { + const g = el("g", {}, pageB), y = BY + 74 + j * 42; + el("rect", { x: BX + 16, y: y - 16, width: BW - 32, height: 32, rx: 10, fill: j === 1 ? YEL_T : "#fff", stroke: INK, "stroke-width": 2 }, g); + el("circle", { cx: BX + 34, cy: y, r: 6, fill: [BLUE, YEL, MINT, LIL, SKY][j], stroke: INK, "stroke-width": 1.8 }, g); + el("rect", { x: BX + 50, y: y - 4, width: [120, 150, 96, 136, 110][j], height: 8, rx: 4, fill: GREY }, g); + el("rect", { x: BX + BW - 64, y: y - 7, width: 32, height: 14, rx: 7, fill: SOFT, stroke: INK, "stroke-width": 1.8 }, g); + return g; + }); + const ptr = el("g", {}, svg), ptrIn = el("g", {}, ptr); + el("path", { d: "M0 0 L0 24 L6.5 18.5 L11 28 L15.5 26 L11 16.5 L19 16 Z", fill: "#fff", stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, ptrIn); + const click = el("circle", { r: 10, fill: "none", stroke: BLUE, "stroke-width": 3, opacity: 0 }, svg); + + /* right: where Claude Code runs */ + const labR = el("g", {}, svg); + fit(txt(labR, 420, 192, "Where Claude Code runs", { "font-size": 21, "font-weight": 700 }), 340); + const panel = (x, y, w, h, band, label) => { const g = el("g", {}, svg); winFrame(g, x, y, w, h, band, label); return { g, cx: x + w / 2, cy: y + h / 2 }; }; + const term = panel(420, 214, 170, 116, BLUE, "Terminal"); + const ide = panel(610, 214, 150, 104, LIL, "IDE"); + const slack = panel(610, 333, 150, 104, MINT, "Slack"); + const web = panel(610, 452, 150, 104, SKY, "Web"); + const PANELS = [term, ide, slack, web]; + /* terminal body: two typed lines and a cursor */ + const defs = el("defs", {}, svg); + const tLines = [[264, 92], [290, 64]].map(([y, w], i) => { + el("path", { d: `M434 ${y - 6} L441 ${y} L434 ${y + 6}`, stroke: INK, "stroke-width": 2.5, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, term.g); + const cp = el("clipPath", { id: `mg-cdd-t${i}` }, defs), wipe = el("rect", { x: 450, y: y - 6, width: 0, height: 12 }, cp); + const g = el("g", { "clip-path": `url(#mg-cdd-t${i})` }, term.g); + el("rect", { x: 450, y: y - 4, width: w * 0.45, height: 8, rx: 4, fill: MUTE }, g); + el("rect", { x: 450 + w * 0.45 + 8, y: y - 4, width: w * 0.55 - 8, height: 8, rx: 4, fill: BLUE }, g); + return { wipe, w, y }; + }); + const cur = el("rect", { x: 450, y: 306, width: 8, height: 14, rx: 1.5, fill: INK }, term.g); + el("path", { d: "M434 307 L441 313 L434 319", stroke: INK, "stroke-width": 2.5, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, term.g); + /* IDE body: a gutter and indented code */ + el("path", { d: "M640 254 V308", stroke: GREY, "stroke-width": 2 }, ide.g); + const ideRows = [[0, 64, LIL], [1, 52, GREY], [1, 70, BLUE], [2, 40, GREY], [0, 30, LIL]].map(([ind, w, c], j) => { + const y = 258 + j * 11; + el("rect", { x: 622, y: y - 2, width: 8, height: 4, rx: 2, fill: GREY }, ide.g); + return el("rect", { x: 650 + ind * 14, y: y - 3, width: 0, height: 6, rx: 3, fill: c, "data-w": w }, ide.g); + }); + /* Slack body: two messages */ + const msgs = [[0, 387, 84], [1, 417, 70]].map(([side, y, w]) => { + const g = el("g", {}, slack.g), x = side ? 744 - w : 626; + el("rect", { x, y: y - 12, width: w, height: 24, rx: 10, fill: side ? MINT : SOFT, stroke: INK, "stroke-width": 2 }, g); + el("rect", { x: x + 12, y: y - 3, width: w - 24, height: 6, rx: 3, fill: side ? "#fff" : GREY }, g); + return { g, x: x + (side ? w : 0), y: y + 12 }; + }); + /* Web body: an address pill and a block */ + const webIn = el("g", {}, web.g); + el("rect", { x: 624, y: 494, width: 122, height: 16, rx: 8, fill: SOFT, stroke: INK, "stroke-width": 1.8 }, webIn); + el("rect", { x: 624, y: 518, width: 56, height: 26, rx: 6, fill: SKY, stroke: INK, "stroke-width": 2 }, webIn); + [0, 1].forEach((j) => el("rect", { x: 690, y: 522 + j * 12, width: [56, 40][j], height: 6, rx: 3, fill: GREY }, webIn)); + + /* the repo tree under the terminal */ + const TX = 451; + const folder = el("g", {}, svg), folderIn = el("g", {}, folder); + const fHalo = el("rect", { x: -20, y: -16, width: 40, height: 32, rx: 9, fill: "none", stroke: MINT, "stroke-width": 4, opacity: 0 }, folder); + el("path", { d: "M-16 -12 H-4 L0 -7 H16 V12 H-16 Z", fill: YEL, stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, folderIn); + el("path", { d: "M-16 -3 H16", stroke: INK, "stroke-width": 2 }, folderIn); + const repoL = el("g", {}, svg); + txt(repoL, 476, 408, "Your repo", { "font-size": 19, "font-weight": 700 }); + const trunk = el("path", { d: `M${TX} 416 V502`, stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round", "stroke-dasharray": "86 90" }, svg); + const files = [438, 470, 502].map((y, j) => { + const g = el("g", {}, svg); + el("path", { d: `M${TX} ${y} H${TX + 16}`, stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round" }, g); + el("path", { d: `M${TX + 20} ${y - 11} H${TX + 31} L${TX + 37} ${y - 5} V${y + 11} H${TX + 20} Z`, fill: "#fff", stroke: INK, "stroke-width": 2.2, "stroke-linejoin": "round" }, g); + el("rect", { x: TX + 46, y: y - 4, width: [76, 58, 92][j], height: 8, rx: 4, fill: GREY }, g); + return g; + }); + /* the dotted line from the terminal down to the repo */ + const DOTS = [340, 350, 360, 370, 380].map((y) => el("circle", { cx: TX, cy: y, r: 3.2, fill: INK }, svg)); + const sig = el("circle", { cx: TX, r: 5.5, fill: MINT, stroke: INK, "stroke-width": 2, opacity: 0 }, svg); + + return (t) => { + /* left */ + const settle = seg(t, 7.6, 8.4), bob = -4 * Math.sin((2 * Math.PI * (t - END)) / 3.4) * settle; + cloudG.setAttribute("transform", `translate(0 ${bob})`); + cloud.forEach((c) => { + const k = c.i === 0 ? oc(seg(t, 0.3, 0.7)) : ob(seg(t, 0.4 + c.i * 0.09, 0.82 + c.i * 0.09)); + const tr = c.i === 0 ? `translate(${c.cx} ${c.cy}) scale(${0.7 + 0.3 * k} ${0.85 + 0.15 * k}) translate(${-c.cx} ${-c.cy})` : `translate(${c.cx} ${c.cy}) scale(${k}) translate(${-c.cx} ${-c.cy})`; + [c.a, c.b].forEach((n) => { n.setAttribute("transform", tr); n.setAttribute("opacity", c.i === 0 ? seg(t, 0.3, 0.45) : k > 0 ? 1 : 0); }); + }); + link.forEach((d, j) => { const k = seg(t, 0.85 + j * 0.08, 1.1 + j * 0.08); d.setAttribute("r", 3 * ob(k)); d.setAttribute("opacity", k > 0 ? 1 : 0); }); + const bx = seg(t, 0.1, 0.6); box.setAttribute("opacity", seg(bx, 0, 0.4)); box.setAttribute("transform", `translate(0 ${22 * (1 - oc(bx))})`); + rise(labL, seg(t, 0.35, 0.8), 12); + const wk = seg(t, 0.9, 1.35); about(browser, 210, 388, 0.82 + 0.18 * ob(wk)); browser.setAttribute("opacity", seg(wk, 0, 0.3)); + const sw = seg(t, 3.05, 3.2), active = sw > 0.5 ? 1 : 0; + TABS.forEach((T, j) => { + const k = seg(t, 1.15 + j * 0.1, 1.45 + j * 0.1); + T.g.setAttribute("transform", `translate(0 ${12 * (1 - ob(k))})`); T.g.setAttribute("opacity", k > 0 ? 1 : 0); + T.r.setAttribute("fill", j === active ? "#fff" : LIL_SOFT); + }); + cover.setAttribute("x", TABS[active].x + 2.5); + cover.setAttribute("opacity", seg(t, 1.3, 1.45)); + addr.setAttribute("opacity", seg(t, 1.45, 1.65)); + url.setAttribute("width", active ? 20 + 100 * oc(seg(t, 3.2, 3.65)) : 150 * oc(seg(t, 1.6, 2.0))); + const fa = 1 - seg(t, 3.08, 3.25); + pageA.setAttribute("opacity", fa); + A.forEach((n, j) => { const k = seg(t, 1.7 + j * 0.07, 2.1 + j * 0.07); n.setAttribute("opacity", seg(k, 0, 0.4)); n.setAttribute("transform", `translate(0 ${10 * (1 - oc(k))})`); }); + B.forEach((n, j) => { const k = seg(t, 3.3 + j * 0.08, 3.7 + j * 0.08); n.setAttribute("opacity", seg(k, 0, 0.4)); n.setAttribute("transform", `translate(${-14 * (1 - oc(k))} 0)`); }); + /* the pointer glides to the second tab and clicks */ + const pm = io(seg(t, 2.35, 3.0)), pa = seg(t, 2.2, 2.4); + const px = 300 + (200 - 300) * pm, py = 470 + (BY - 16 - 470) * pm - 30 * Math.sin(Math.PI * pm); + const pe = io(seg(t, 3.35, 3.9)), qx = px + 34 * pe, qy = py + 6 * pe; + const pc = dip(seg(t, 3.0, 3.18)); + tf(ptr, qx, qy, 1 - 0.12 * pc); ptr.setAttribute("opacity", pa); + const ck = seg(t, 3.02, 3.5); tf(click, 200, BY - 16, 0.4 + 1.4 * oc(ck)); click.setAttribute("opacity", ck > 0 && ck < 1 ? 1 - ck : 0); + + /* right */ + rise(labR, seg(t, 2.6, 3.05), 12); + PANELS.forEach((P, j) => { + const k = seg(t, 3.2 + j * 0.55, 3.7 + j * 0.55); + P.g.setAttribute("transform", `translate(0 ${18 * (1 - oc(k))}) translate(${P.cx} ${P.cy}) scale(${0.6 + 0.4 * ob(k)}) translate(${-P.cx} ${-P.cy})`); + P.g.setAttribute("opacity", seg(k, 0, 0.3)); + }); + let typing = false; + tLines.forEach((L, j) => { const a = 3.7 + j * 0.55, p = Math.floor(seg(t, a, a + 0.45) * 8) / 8; L.wipe.setAttribute("width", p * (L.w + 2)); if (t >= a && t < a + 0.5) typing = true; }); + cur.setAttribute("opacity", seg(t, 3.5, 3.6) > 0 && (typing || blink(t, END)) ? 1 : 0); + ideRows.forEach((n, j) => n.setAttribute("width", +n.getAttribute("data-w") * oc(seg(t, 4.2 + j * 0.08, 4.5 + j * 0.08)))); + msgs.forEach((M, j) => { const k = seg(t, 4.8 + j * 0.3, 5.15 + j * 0.3); about(M.g, M.x, M.y, ob(k)); M.g.setAttribute("opacity", k > 0 ? 1 : 0); }); + const wb = seg(t, 5.35, 5.7); webIn.setAttribute("opacity", seg(wb, 0, 0.5)); webIn.setAttribute("transform", `translate(0 ${8 * (1 - oc(wb))})`); + + /* the repo, then the dotted line, then a connect flash and an idle signal */ + const fk = seg(t, 5.7, 6.15), hit = seg(t, 7.35, 7.95); + tf(folder, TX, 400, ob(fk) * (1 + 0.1 * dip(seg(t, 7.35, 7.7))), -10 * (1 - ob(fk))); + folder.setAttribute("opacity", fk > 0 ? 1 : 0); + const q = squash(seg(t, 7.35, 7.85)); folderIn.setAttribute("transform", `scale(${1 + q} ${1 - q})`); + fHalo.setAttribute("opacity", hit > 0 && hit < 1 ? 1 - hit : 0); fHalo.setAttribute("transform", `scale(${1 + 0.6 * oc(hit)})`); + rise(repoL, seg(t, 5.85, 6.3), 10); + trunk.setAttribute("stroke-dashoffset", 86 * (1 - oc(seg(t, 6.0, 6.4)))); + files.forEach((g, j) => { const k = seg(t, 6.15 + j * 0.1, 6.5 + j * 0.1); g.setAttribute("opacity", seg(k, 0, 0.4)); g.setAttribute("transform", `translate(${-12 * (1 - oc(k))} 0)`); }); + DOTS.forEach((d, j) => { const k = seg(t, 6.75 + j * 0.11, 7.0 + j * 0.11); d.setAttribute("r", 3.2 * ob(k)); d.setAttribute("opacity", k > 0 ? 1 : 0); }); + const u = ((((t - END - 0.4) / 2.6) % 1) + 1) % 1, on = seg(t, 8.2, 8.3) > 0 && u < 0.45; + const su = u / 0.45; + sig.setAttribute("cy", 334 + 50 * io(su)); sig.setAttribute("opacity", on ? Math.sin(Math.PI * su) : 0); + }; + }, + }; + + /* ---- 3. a day from 08:00 to 08:00 with three bands and a scrubber ---- */ + SC.day = { + dur: 13, still: 13, + build(svg, api) { + const END = 13, X0 = 268, X1 = 716, PH = (X1 - X0) / 24, AY = 490, READY = 5.6, TOP = 140, RY = 122; + const LY = [194, 304, 414]; + const hx = (h) => X0 + h * PH; + const hintT = hint(svg, "Drag the time", 756, 50); + const BANDS = [ + { a: 0, b: 1.5, c: YEL, name: "Claude Duet:", sub: "morning brief" }, + { a: 2, b: 10, c: BLUE, name: "Claude Code:", sub: "you at the keyboard" }, + { a: 14, b: 22, c: MINT, name: "Munder Difflin:", sub: "scheduled agents" }, + ]; + /* grid, tracks, axis */ + const grid = el("g", {}, svg); + [0, 4, 10, 16, 22, 24].forEach((h) => el("path", { d: `M${hx(h)} ${TOP + 16} V${AY - 8}`, stroke: GREY, "stroke-width": 2, "stroke-dasharray": "2 7", "stroke-linecap": "round" }, grid)); + const tracks = LY.map((y) => el("rect", { x: X0, y: y - 15, width: X1 - X0, height: 30, rx: 15, fill: SOFT }, svg)); + const axis = el("path", { d: `M${X0} ${AY} H${X1}`, stroke: INK, "stroke-width": 3, "stroke-linecap": "round", "stroke-dasharray": `${X1 - X0} ${X1 - X0 + 10}` }, svg); + const hrs = el("g", {}, svg); + for (let h = 1; h < 24; h++) if (![4, 10, 16, 22].includes(h)) el("path", { d: `M${hx(h)} ${AY - 4} V${AY + 4}`, stroke: MUTE, "stroke-width": 1.8, "stroke-linecap": "round" }, hrs); + const MARKS = [[0, "08:00", "end"], [4, "12:00"], [10, "18:00"], [16, "00:00"], [22, "06:00"], [24, "08:00", "start"]].map(([h, s, side]) => { + const g = el("g", {}, svg); + el("path", { d: `M${hx(h)} ${AY - (side ? 10 : 8)} V${AY + (side ? 10 : 8)}`, stroke: INK, "stroke-width": 3, "stroke-linecap": "round" }, g); + if (side) mono(g, hx(h) + (side === "end" ? -23 : 23), AY + 6, s, { "text-anchor": side, "font-size": 17, fill: INK }); + else mono(g, hx(h), AY + 40, s, { "text-anchor": "middle", "font-size": 17, fill: MUTE }); + return { g, x: hx(h) }; + }); + /* lane labels in the left column */ + const labs = BANDS.map((B, i) => { + const g = el("g", {}, svg), y = LY[i] + (i === 0 ? -10 : 3); + const n = txt(g, 40, y - 4, B.name, { "font-size": 21, "font-weight": 700 }); + const s = txt(g, 40, y + 21, B.sub, { "font-size": 19, "font-weight": 700 }); + fit(n, 206); fit(s, 206); + s.setAttribute("font-weight", 500); n.setAttribute("font-weight", 600); + return { g, n, s }; + }); + const legend = el("g", {}, svg); + el("rect", { x: 40, y: LY[0] + 26, width: 8, height: 20, rx: 3, fill: YEL, stroke: INK, "stroke-width": 2 }, legend); + txt(legend, 58, LY[0] + 42, "checks", { "font-size": 18, "font-weight": 500, fill: MUTE }); + /* bands with a hard shadow that shows when they lift */ + const bands = BANDS.map((B, i) => { + const x = hx(B.a), w = (B.b - B.a) * PH, y = LY[i] - 15; + const sh = el("rect", { x, y, width: w, height: 30, rx: 12, fill: INK, opacity: 0 }, svg); + const g = el("g", {}, svg); + const r = el("rect", { x, y, width: w, height: 30, rx: 12, fill: B.c, stroke: INK, "stroke-width": 2.5 }, g); + return Object.assign({ sh, g, r, x, w, y, on: false, at: -99 }, B); + }); + const checks = [4, 8, 12, 16, 20].map((h) => { + const g = el("g", {}, svg); + el("rect", { x: -4, y: -11, width: 8, height: 22, rx: 3, fill: YEL, stroke: INK, "stroke-width": 2 }, g); + return { g, h, x: hx(h) }; + }); + /* the scrubber: knob first so the focus styles find it, then the line and the readout */ + const scrub = el("g", { role: "slider", tabindex: "0", "aria-label": "Time of day", "aria-valuemin": 0, "aria-valuemax": 24, class: "mg-hit" }, svg); + el("circle", { r: 26, fill: "#fff", "fill-opacity": 0 }, scrub); + const knob = el("circle", { r: 14, fill: YEL, stroke: INK, "stroke-width": 3 }, scrub); + el("circle", { r: 21, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 4", class: "mg-ring" }, scrub); + const halo = el("circle", { r: 14, fill: "none", stroke: YEL, "stroke-width": 4, opacity: 0 }, scrub); + const line = el("path", { d: `M0 -14 V${TOP - AY}`, stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round" }, scrub); + const ro = el("g", {}, scrub); + el("rect", { x: -42, y: -17, width: 84, height: 34, rx: 17, fill: INK }, ro); + const roT = mono(ro, 0, 6.5, "08:00", { "text-anchor": "middle", "font-size": 18, fill: "#fff" }); + const hit = el("rect", { x: X0 - 24, y: TOP - 4, width: X1 - X0 + 48, height: AY - TOP + 34, fill: "#fff", "fill-opacity": 0 }, svg); + svg.insertBefore(hit, scrub); + const clock = (h) => String((8 + Math.round(h)) % 24).padStart(2, "0") + ":00"; + + /* scrubber state: null follows the autoplay, a number is the viewer's own hour */ + const autoV = (t) => 6 * io(seg(t, 6.0, 7.4)) + 12 * io(seg(t, 8.4, 10.2)); + let user = null, drag = null, down = false, lastT = 0, snapFrom = 0, snapAt = -99; + const inBand = (B, v) => v >= B.a - 0.01 && v <= B.b + 0.01; + const setV = (v, now) => { user = v; bands.forEach((B) => { const on = inBand(B, v); if (B.on !== on) { B.on = on; B.at = now; } }); api.poke(); }; + const first = () => { if (user === null) { const v = Math.round(autoV(lastT)); bands.forEach((B) => { B.on = inBand(B, v); B.at = -99; }); user = v; if (lastT < READY) api.seek(READY); } }; + const fromX = (e) => { const r = svg.getBoundingClientRect(); return cl((((e.clientX - r.left) / r.width) * 800 - X0) / PH, 0, 24); }; + const dragTo = (e) => { first(); drag = fromX(e); setV(Math.round(drag), api.now()); }; + [scrub, hit].forEach((n) => n.addEventListener("pointerdown", (e) => { down = true; try { scrub.setPointerCapture(e.pointerId); } catch (_) {} dragTo(e); scrub.focus && scrub.focus({ preventScroll: true }); })); + scrub.addEventListener("pointermove", (e) => { if (down) dragTo(e); }); + const up = () => { if (drag !== null) { snapFrom = drag; snapAt = api.now(); } down = false; drag = null; api.poke(); }; + scrub.addEventListener("pointerup", up); scrub.addEventListener("pointercancel", up); + scrub.addEventListener("keydown", (e) => { + const k = e.key, cur = user === null ? Math.round(autoV(lastT)) : user; + const n = k === "ArrowRight" || k === "ArrowUp" ? cur + 1 : k === "ArrowLeft" || k === "ArrowDown" ? cur - 1 : k === "PageUp" ? cur + 3 : k === "PageDown" ? cur - 3 : k === "Home" ? 0 : k === "End" ? 24 : null; + if (n === null) return; + e.preventDefault(); first(); snapFrom = cur; snapAt = api.now(); setV(Math.max(0, Math.min(24, n)), api.now()); + }); + + return (t, now) => { + if (t < lastT - 0.5 && !down) user = null; + lastT = t; + const calm = api.calm; + grid.setAttribute("opacity", seg(t, 0.1, 0.6)); + tracks.forEach((n, i) => { const k = seg(t, 0.5 + i * 0.1, 0.95 + i * 0.1); n.setAttribute("opacity", seg(k, 0, 0.4)); n.setAttribute("transform", `translate(${-24 * (1 - oc(k))} 0)`); }); + axis.setAttribute("stroke-dashoffset", (X1 - X0) * (1 - oc(seg(t, 0.2, 0.95)))); + hrs.setAttribute("opacity", seg(t, 0.7, 1.1)); + MARKS.forEach((M, i) => { const k = seg(t, 0.4 + i * 0.09, 0.8 + i * 0.09); M.g.setAttribute("opacity", seg(k, 0, 0.3)); M.g.setAttribute("transform", `translate(0 ${10 * (1 - ob(k))})`); }); + hintT.setAttribute("opacity", seg(t, READY - 0.3, READY + 0.1)); + + /* where the scrubber is */ + let v; + if (drag !== null) v = drag; + else if (user !== null) v = calm ? user : snapFrom + (user - snapFrom) * oc(seg(now - snapAt, 0, 0.22)); + else v = autoV(t); + const shownH = user !== null ? user : Math.round(v); + + bands.forEach((B, i) => { + let lift; + if (user !== null) lift = calm ? +B.on : B.on ? bk(seg(now - B.at, 0, 0.35), 2) : 1 - io(seg(now - B.at, 0, 0.25)); + else { const e = Math.min(seg(v, B.a - 0.35, B.a + 0.05), 1 - seg(v, B.b - 0.05, B.b + 0.35)); lift = t < 5.5 ? 0 : bk(e, 2); } + const k = i === 0 ? seg(t, 1.5, 1.9) : seg(t, 3.0 + (i - 1) * 1.0, 3.8 + (i - 1) * 1.0); + const pop = i === 0 ? Math.max(0.01, ob(k)) : 1, cx = B.x + B.w / 2; + if (i > 0) { const w = Math.max(0.1, B.w * oc(k)); B.r.setAttribute("width", w); B.sh.setAttribute("width", w); } + B.g.setAttribute("opacity", k > 0 ? 1 : 0); + B.g.setAttribute("transform", `translate(0 ${-6 * lift}) translate(${cx} ${LY[i]}) scale(${pop}) translate(${-cx} ${-LY[i]})`); + B.sh.setAttribute("opacity", k >= 1 ? cl(lift * 3) : 0); + const L = labs[i], lk = seg(t, [1.4, 2.9, 3.9][i], [1.85, 3.35, 4.35][i]); + rise(L.g, lk, 12); + const bold = lift > 0.5; + L.n.setAttribute("font-weight", bold ? 700 : 600); L.s.setAttribute("font-weight", bold ? 700 : 500); + L.s.setAttribute("fill", mix(MUTE, INK, cl(lift))); + }); + checks.forEach((C, j) => { + const k = seg(t, 2.0 + j * 0.09, 2.35 + j * 0.09), near = t > 5.5 ? cl(1 - Math.abs(v - C.h) / 0.6) : 0; + tf(C.g, C.x, LY[0] - 5 * near, ob(k) * (1 + 0.18 * near)); C.g.setAttribute("opacity", k > 0 ? 1 : 0); + }); + rise(legend, seg(t, 2.6, 3.0), 8); + + /* the scrubber arrives at 08:00, then sweeps; it pulses softly once it rests */ + const sk = seg(t, 5.0, 5.4), lk2 = oc(seg(t, 5.1, 5.55)), rk = seg(t, 5.35, 5.75); + scrub.setAttribute("transform", `translate(${hx(v)} ${AY})`); + scrub.setAttribute("opacity", sk > 0 ? 1 : 0); + knob.setAttribute("r", 14 * ob(sk)); + line.setAttribute("d", `M0 -14 V${-14 + (TOP - AY + 14) * lk2}`); + line.setAttribute("opacity", lk2 > 0.02 ? 1 : 0); + const rx = cl(hx(v), 40 + 42, 800 - 40 - 42) - hx(v); + ro.setAttribute("transform", `translate(${rx} ${RY - AY}) scale(${0.4 + 0.6 * ob(rk)})`); + ro.setAttribute("opacity", seg(rk, 0, 0.3)); + roT.textContent = clock(shownH); + scrub.setAttribute("aria-valuenow", Math.round(shownH)); + scrub.setAttribute("aria-valuetext", clock(shownH)); + const pu = calm ? 0 : bump(t, END, 2.2, 0, 0.6) * seg(t, 10.6, 11.0); + const pk = ((((t - END) / 2.2) % 1) + 1) % 1 / 0.6; + halo.setAttribute("r", 14 + 16 * cl(pk)); halo.setAttribute("opacity", pu); + }; + }, + }; + + /* ---- player ---- */ + const q = new URLSearchParams(location.search), frozen = q.has("mgstill"), fixed = parseFloat(q.get("mgstill")); + const calm = matchMedia("(prefers-reduced-motion: reduce)").matches; + const mount = (fig) => { + const sc = SC[fig.dataset.scene], img = fig.querySelector("img"); if (!sc || !img) return; + const W = sc.w || 800, H = sc.h || 600; + const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "mg-svg", role: "group", "aria-label": img.alt || "" }); + svg.style.aspectRatio = `${W} / ${H}`; + el("rect", { width: W, height: H, fill: "#fff" }, svg); + img.replaceWith(svg); + let off = 0, raf = 0, seen = false, held = calm; + const base = performance.now(), clock = () => (performance.now() - base) / 1000; + let draw = () => {}; + const frame = () => { const now = clock(); draw(held ? sc.still : (((now + off) % sc.dur) + sc.dur) % sc.dur, now); }; + const tick = () => { cancelAnimationFrame(raf); frame(); if (seen && !held) raf = requestAnimationFrame(tick); }; + // with reduced motion a tap redraws the still frame with the new choice instead of starting playback + const api = { now: clock, calm, seek: (s) => { if (calm) return frame(); off = s - clock(); held = false; tick(); }, poke: () => { if (calm) return frame(); held = false; tick(); } }; + draw = sc.build(svg, api); + if (frozen) { draw(isNaN(fixed) ? sc.still : fixed, 0); return; } + new IntersectionObserver(([e]) => { + seen = e.isIntersecting; + if (seen) { tick(); if (!fig.dataset.sent && window.posthog) { fig.dataset.sent = 1; window.posthog.capture("blog_scene_view", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } } + else cancelAnimationFrame(raf); + }, { threshold: 0.25 }).observe(fig); + svg.addEventListener("click", () => { if (!fig.dataset.hit && window.posthog) { fig.dataset.hit = 1; window.posthog.capture("blog_scene_interact", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } }); + frame(); + }; + const go = () => document.querySelectorAll("figure.mg[data-scene]").forEach(mount); + const fonts = document.fonts && document.fonts.load ? Promise.all([document.fonts.load('700 32px "Space Grotesk"'), document.fonts.load('500 20px "Space Grotesk"'), document.fonts.load('700 20px "JetBrains Mono"')]).catch(() => {}) : Promise.resolve(); + const start = () => fonts.then(go, go); + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); +})(); diff --git a/blog/src/assets/media/claude-duet-for-developers/venn.png b/blog/src/assets/media/claude-duet-for-developers/venn.png new file mode 100644 index 0000000000..ae7d386192 Binary files /dev/null and b/blog/src/assets/media/claude-duet-for-developers/venn.png differ diff --git a/blog/src/assets/media/claude-duet-vs-munder-difflin/hero.png b/blog/src/assets/media/claude-duet-vs-munder-difflin/hero.png new file mode 100644 index 0000000000..5373c80b15 Binary files /dev/null and b/blog/src/assets/media/claude-duet-vs-munder-difflin/hero.png differ diff --git a/blog/src/assets/media/claude-duet-vs-munder-difflin/leash.png b/blog/src/assets/media/claude-duet-vs-munder-difflin/leash.png new file mode 100644 index 0000000000..0b74bc87b5 Binary files /dev/null and b/blog/src/assets/media/claude-duet-vs-munder-difflin/leash.png differ diff --git a/blog/src/assets/media/claude-duet-vs-munder-difflin/limits.png b/blog/src/assets/media/claude-duet-vs-munder-difflin/limits.png new file mode 100644 index 0000000000..189d732fcf Binary files /dev/null and b/blog/src/assets/media/claude-duet-vs-munder-difflin/limits.png differ diff --git a/blog/src/assets/media/claude-duet-vs-munder-difflin/motion.css b/blog/src/assets/media/claude-duet-vs-munder-difflin/motion.css new file mode 100644 index 0000000000..9053c6faf6 --- /dev/null +++ b/blog/src/assets/media/claude-duet-vs-munder-difflin/motion.css @@ -0,0 +1,13 @@ +/* Bright coded scenes for "Claude Duet vs Munder Difflin". Pairs with motion.js. */ +.prose figure.mg { margin: 42px 0; } +.prose figure.mg img, .mg .mg-svg { display: block; width: 100%; height: auto; border: 1px solid #1A1320; border-radius: 18px; background: #fff; } +.mg figcaption { font: 500 13px/1.5 "Space Grotesk", system-ui, sans-serif; color: #6B5878; padding-top: 10px; text-align: center; } +.mg .mg-svg { touch-action: manipulation; -webkit-tap-highlight-color: transparent; } +.mg .mg-svg text { -webkit-user-select: none; user-select: none; pointer-events: none; } +.mg .mg-hit { cursor: pointer; outline: none; } +.mg [role="slider"] { cursor: grab; touch-action: none; } +.mg .mg-ring { stroke-opacity: 0; transition: stroke-opacity 0.15s; } +.mg .mg-hit:focus-visible .mg-ring { stroke-opacity: 1; } +.mg [role="slider"]:focus-visible circle:nth-child(2) { stroke-width: 5; } +@media (hover: hover) { .mg .mg-hit:hover .mg-ring { stroke-opacity: 0.5; } } +@media (prefers-reduced-motion: reduce) { .mg .mg-ring { transition: none; } } diff --git a/blog/src/assets/media/claude-duet-vs-munder-difflin/motion.js b/blog/src/assets/media/claude-duet-vs-munder-difflin/motion.js new file mode 100644 index 0000000000..904fbb1cdb --- /dev/null +++ b/blog/src/assets/media/claude-duet-vs-munder-difflin/motion.js @@ -0,0 +1,656 @@ +/* Bright coded scenes for "Claude Duet vs Munder Difflin". Vanilla JS, no library. +
holds a still image; this script swaps in a live SVG. + Plays only in view, honours reduced motion, "?mgstill" freezes each scene on its still frame. */ +(() => { + const NS = "http://www.w3.org/2000/svg"; + const INK = "#1A1320", YEL = "#FFCA54", BLUE = "#6C8EF5", LIL = "#B69CFF", MINT = "#7FD8AE", SKY = "#9AD8FF", SOFT = "#F1ECF9", MUTE = "#7A6A88"; + const SKY_SOFT = "#DDF1FF", GREY = "#CAC3D2", DIM = "#4A3F57"; + const SANS = '"Space Grotesk", system-ui, sans-serif', MONO = '"JetBrains Mono", ui-monospace, monospace'; + const el = (t, a = {}, p) => { const n = document.createElementNS(NS, t); for (const k in a) n.setAttribute(k, a[k]); if (p) p.appendChild(n); return n; }; + const txt = (p, x, y, s, a = {}) => { const n = el("text", Object.assign({ x, y, "font-family": SANS, "font-size": 24, "font-weight": 600, fill: INK }, a), p); n.textContent = s; return n; }; + const fit = (n, max) => { const w = n.getComputedTextLength(); if (w > max) n.setAttribute("font-size", (+n.getAttribute("font-size") * max) / w); return n; }; + const cl = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v)); + const seg = (t, a, b) => cl((t - a) / (b - a)); + const oc = (x) => 1 - Math.pow(1 - x, 3); + const ob = (x) => { const c = 1.70158; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); }; + const io = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); + const tf = (n, x, y, s = 1, r = 0) => n.setAttribute("transform", `translate(${x} ${y}) rotate(${r}) scale(${s})`); + const hex = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + const mix = (a, b, k) => { const A = hex(a), B = hex(b); return "#" + A.map((v, i) => Math.round(v + (B[i] - v) * cl(k)).toString(16).padStart(2, "0")).join(""); }; + /* a soft idle beat: 0 at rest, a smooth hump for the first w of each period P; always at rest when t = end */ + const bump = (t, end, P, d = 0, w = 0.5) => { if (!isFinite(t)) return 0; const u = ((((t - end - d) / P) % 1) + 1) % 1; return u < w ? Math.sin(Math.PI * u / w) : 0; }; + /* a squash and stretch wobble after a landing: k runs 0 to 1 */ + const squash = (k) => (k > 0 && k < 1 ? Math.sin(k * Math.PI * 2) * (1 - k) * 0.14 : 0); + const button = (g, label, fn) => { + g.setAttribute("role", "button"); g.setAttribute("tabindex", "0"); g.setAttribute("aria-label", label); g.classList.add("mg-hit"); + g.addEventListener("click", fn); + g.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); } }); + }; + const hint = (svg, s, x = 756, y = 50) => txt(svg, x, y, s, { "text-anchor": "end", "font-size": 16, fill: MUTE, "font-weight": 500 }); + /* a rounded token centred on its origin, sized to its label, with an optional coloured dot */ + const token = (p, label, fill, dot, fs = 17, h = 32) => { + const g = el("g", {}, p), inner = el("g", {}, g); + const halo = el("rect", { y: -h / 2, height: h, rx: h / 2, fill: "none", stroke: SKY, "stroke-width": 4, opacity: 0 }, inner); + const r = el("rect", { y: -h / 2, height: h, rx: h / 2, fill, stroke: INK, "stroke-width": 2.5 }, inner); + const padL = dot ? 30 : 15; + const t = txt(inner, 0, fs * 0.36, label, { "font-size": fs, "font-weight": 700 }); + const w = Math.ceil(t.getComputedTextLength()) + padL + 15; + t.setAttribute("x", -w / 2 + padL); + [r, halo].forEach((n) => { n.setAttribute("x", -w / 2); n.setAttribute("width", w); }); + const d = dot ? el("circle", { cx: -w / 2 + 16, cy: 0, r: 6.5, fill: dot, stroke: INK, "stroke-width": 2 }, inner) : null; + return { g, inner, halo, r, t, d, w, h }; + }; + /* a pill button with a dashed focus ring */ + const mkBtn = (p, label, fs = 18) => { + const g = el("g", {}, p), body = el("g", {}, g); + const r = el("rect", { y: -21, height: 42, rx: 21, fill: YEL, stroke: INK, "stroke-width": 2.5 }, body); + const t = txt(body, 0, 6.5, label, { "text-anchor": "middle", "font-size": fs, "font-weight": 700 }); + const w = Math.ceil(t.getComputedTextLength()) + 44; + r.setAttribute("x", -w / 2); r.setAttribute("width", w); + el("rect", { x: -w / 2 - 6, y: -27, width: w + 12, height: 54, rx: 27, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, body); + return { g, body, r, w }; + }; + const dip = (k) => (k > 0 && k < 1 ? Math.sin(Math.PI * k) : 0); + + const SC = {}; + + /* ---- title card, 16 by 9: one card in a cloud beside four typing terminals ---- */ + SC.hero = { + w: 960, h: 540, dur: 10, still: 10, + build(svg) { + const END = 10; + const pill = el("g", {}, svg); + const pr = el("rect", { y: -20, height: 40, rx: 20, fill: YEL, stroke: INK, "stroke-width": 2.5 }, pill); + const pt = txt(pill, 0, 7, "Versus", { "text-anchor": "middle", "font-size": 19, "font-weight": 700 }); + const pw = Math.ceil(pt.getComputedTextLength()) + 40; + pr.setAttribute("x", -pw / 2); pr.setAttribute("width", pw); + const l1g = el("g", {}, svg), l2g = el("g", {}, svg); + const l1 = txt(l1g, 60, 194, "Claude Duet ", { "font-size": 68, "font-weight": 700 }); + el("tspan", { fill: MUTE }, l1).textContent = "vs"; + const l2 = txt(l2g, 60, 268, "Munder Difflin", { "font-size": 68, "font-weight": 700 }); + const TW = 400, tmax = Math.max(l1.getComputedTextLength(), l2.getComputedTextLength()); + if (tmax > TW) [l1, l2].forEach((n) => n.setAttribute("font-size", (68 * TW) / tmax)); + const UL = Math.round(l2.getComputedTextLength() * 0.62); + const under = el("path", { d: `M66 292 H${66 + UL}`, stroke: YEL, "stroke-width": 14, "stroke-linecap": "round", fill: "none" }, svg); + const s1g = el("g", {}, svg), s2g = el("g", {}, svg); + const s1 = txt(s1g, 63, 342, "One agent in the cloud,", { "font-size": 26, "font-weight": 500, fill: MUTE }); + const s2 = txt(s2g, 63, 376, "or a team on your computer", { "font-size": 26, "font-weight": 500, fill: MUTE }); + const smax = Math.max(s1.getComputedTextLength(), s2.getComputedTextLength()); + if (smax > 418) [s1, s2].forEach((n) => n.setAttribute("font-size", (26 * 418) / smax)); + const chk = txt(svg, 64, 470, "Checked 22 Oct 2026", { "font-size": 18, "font-weight": 500, fill: MUTE }); + + /* the cloud: an outline layer under a fill layer gives one merged shape */ + const cloudG = el("g", {}, svg), cS = el("g", {}, cloudG), cF = el("g", {}, cloudG); + const SH = [["r", 500, 232, 156, 74, 37], ["c", 544, 236, 38], ["c", 598, 210, 50], ["c", 642, 240, 32]]; + const cloud = SH.map((s, i) => { + const mk = (p, a) => s[0] === "r" ? el("rect", Object.assign({ x: s[1], y: s[2], width: s[3], height: s[4], rx: s[5] }, a), p) : el("circle", Object.assign({ cx: s[1], cy: s[2], r: s[3] }, a), p); + return { a: mk(cS, { fill: INK, stroke: INK, "stroke-width": 6 }), b: mk(cF, { fill: SKY_SOFT }), cx: s[0] === "r" ? s[1] + s[3] / 2 : s[1], cy: s[0] === "r" ? s[2] + s[4] / 2 : s[2], i }; + }); + const card = el("g", {}, cloudG); + el("rect", { x: -44, y: -31, width: 88, height: 62, rx: 10, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, card); + const lines = [[-15, 58], [-2, 42], [11, 52]].map(([y, w]) => ({ n: el("rect", { x: -31, y: y - 3.5, width: w, height: 7, rx: 3.5, fill: GREY }, card), w })); + + /* the vs chip */ + const chip = el("g", {}, svg); + el("circle", { r: 26, fill: YEL, stroke: INK, "stroke-width": 2.5 }, chip); + txt(chip, 0, 7.5, "vs", { "text-anchor": "middle", "font-size": 22, "font-weight": 700 }); + + /* four terminals */ + const defs = el("defs", {}, svg); + const SEGS = [[[-22, 14], [-4, 20], [20, 8]], [[-22, 20], [2, 12], [18, 10]], [[-22, 10], [-8, 24], [20, 6]], [[-22, 16], [-2, 16], [18, 10]]]; + const COLS = [BLUE, LIL, MINT, SKY]; + const terms = [[792, 224], [890, 224], [792, 312], [890, 312]].map(([x, y], i) => { + const g = el("g", {}, svg); + el("rect", { x: -44, y: -38, width: 88, height: 76, rx: 10, fill: "#fff" }, g); + el("path", { d: "M-44 -20 V-28 A10 10 0 0 1 -34 -38 H34 A10 10 0 0 1 44 -28 V-20 Z", fill: COLS[i] }, g); + el("path", { d: "M-44 -20 H44", stroke: INK, "stroke-width": 2 }, g); + [-33, -25, -17].forEach((cx) => el("circle", { cx, cy: -29, r: 2.8, fill: "#fff", stroke: INK, "stroke-width": 1.5 }, g)); + el("rect", { x: -44, y: -38, width: 88, height: 76, rx: 10, fill: "none", stroke: INK, "stroke-width": 2.5 }, g); + el("path", { d: "M-33 3 L-27 9 L-33 15", stroke: INK, "stroke-width": 2.5, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, g); + const cp = el("clipPath", { id: `mg-dvm-term${i}` }, defs); + const wipe = el("rect", { x: -22, y: 0, width: 0, height: 18 }, cp); + const typed = el("g", { "clip-path": `url(#mg-dvm-term${i})` }, g); + SEGS[i].forEach(([sx, sw]) => el("rect", { x: sx, y: 5.5, width: sw, height: 7, rx: 3.5, fill: MUTE }, typed)); + const endX = SEGS[i][2][0] + SEGS[i][2][1]; + const cur = el("rect", { x: -20, y: 2.5, width: 5, height: 13, rx: 1, fill: INK }, g); + return { g, wipe, cur, endX, x, y, at: 2.3 + i * 0.09, ts: 3.0 + i * 0.32 }; + }); + + return (t) => { + const a = ob(seg(t, 0.15, 0.6)); + tf(pill, 64 + pw / 2, 82, 0.6 + 0.4 * a); pill.setAttribute("opacity", seg(t, 0.15, 0.3)); + [l1g, l2g].forEach((g, i) => { const k = seg(t, 0.32 + i * 0.1, 0.92 + i * 0.1); tf(g, 0, 26 * (1 - oc(k))); g.setAttribute("opacity", seg(k, 0, 0.4)); }); + const ul = oc(seg(t, 0.85, 1.4)); under.setAttribute("stroke-dasharray", `${UL} ${UL + 20}`); under.setAttribute("stroke-dashoffset", UL * (1 - ul)); under.setAttribute("opacity", ul > 0 ? 1 : 0); + [s1g, s2g].forEach((g, i) => { const k = seg(t, 0.62 + i * 0.09, 1.2 + i * 0.09); tf(g, 0, 16 * (1 - oc(k))); g.setAttribute("opacity", seg(k, 0, 0.5)); }); + chk.setAttribute("opacity", seg(t, 0.85, 1.25)); + + const settle = seg(t, 4.4, 5.2); + const bob = -5 * Math.sin((2 * Math.PI * (t - END)) / 3.4) * settle; + cloudG.setAttribute("transform", `translate(0 ${bob})`); + cloud.forEach((c) => { + const k = c.i === 0 ? oc(seg(t, 0.95, 1.4)) : ob(seg(t, 1.05 + c.i * 0.09, 1.5 + c.i * 0.09)); + const tr = c.i === 0 ? `translate(${c.cx} ${c.cy}) scale(${0.7 + 0.3 * k} ${0.85 + 0.15 * k}) translate(${-c.cx} ${-c.cy})` : `translate(${c.cx} ${c.cy}) scale(${k}) translate(${-c.cx} ${-c.cy})`; + [c.a, c.b].forEach((n) => { n.setAttribute("transform", tr); n.setAttribute("opacity", c.i === 0 ? seg(t, 0.95, 1.15) : k > 0 ? 1 : 0); }); + }); + const ce = seg(t, 1.5, 2.0); + tf(card, 580, 262 - 34 * (1 - oc(ce)), 0.85 + 0.15 * ob(ce), -3 + 10 * (1 - ob(ce)) + 1.2 * Math.sin((2 * Math.PI * (t - END)) / 3.4 + 0.9) * settle); + card.setAttribute("opacity", seg(ce, 0, 0.3)); + lines.forEach((L, j) => L.n.setAttribute("width", L.w * oc(seg(t, 1.9 + j * 0.1, 2.3 + j * 0.1)))); + + const he = seg(t, 2.1, 2.55); + tf(chip, 708, 266, ob(he) * (1 + 0.05 * bump(t, END, 2.2, 0.6, 0.4) * settle), -25 * (1 - ob(he))); + chip.setAttribute("opacity", he > 0 ? 1 : 0); + + terms.forEach((T) => { + const k = seg(t, T.at, T.at + 0.5); + tf(T.g, T.x, T.y + 14 * (1 - oc(k)), 0.6 + 0.4 * ob(k)); + T.g.setAttribute("opacity", seg(k, 0, 0.3)); + const p = Math.floor(seg(t, T.ts, T.ts + 1.0) * 9) / 9, rx = -22 + p * (T.endX + 22); + T.wipe.setAttribute("width", rx + 22); + T.cur.setAttribute("x", p > 0 ? rx + 2 : -20); + const typing = t >= T.ts && t < T.ts + 1.05; + const on = typing || ((((t - END) / 0.9) % 1) + 1) % 1 < 0.55; + T.cur.setAttribute("opacity", on ? 1 : 0); + }); + }; + }, + }; + + /* ---- 1. one ongoing thread beside a team that passes envelopes through a shared hive ---- */ + SC.team = { + dur: 13, still: 13, + build(svg, api) { + const END = 13, ADD = 8.3; + const tL = txt(svg, 48, 62, "Claude Duet", { "font-size": 26, "font-weight": 700 }); + const tR = txt(svg, 384, 62, "Munder Difflin", { "font-size": 26, "font-weight": 700 }); + const hintT = hint(svg, "Tap Add an agent", 772, 62); + + /* left: one tall thread */ + const colG = el("g", {}, svg); + el("rect", { x: 48, y: 86, width: 290, height: 492, rx: 24, fill: SOFT }, colG); + const lab = txt(svg, 72, 124, "One ongoing thread", { "font-size": 18, "font-weight": 700, fill: MUTE }); + const rr = (w, h, r, bl, br) => `M${r} 0 H${w - r} A${r} ${r} 0 0 1 ${w} ${r} V${h - br} A${br} ${br} 0 0 1 ${w - br} ${h} H${bl} A${bl} ${bl} 0 0 1 0 ${h - bl} V${r} A${r} ${r} 0 0 1 ${r} 0 Z`; + const BUB = [[0, [118, 84]], [1, [96]], [0, [130]], [0, [124, 70]], [1, [88]], [0, [110, 128]], [1, [104]], [0, null]]; + let by = 142; + const bubbles = BUB.map(([side, ls], i) => { + const h = ls && ls.length > 1 ? 52 : 36, w = ls ? Math.max(...ls) + 40 : 78; + const x = side ? 318 - w : 68, y = by; by += h + 10; + const g = el("g", {}, svg); + el("path", { d: rr(w, h, 15, side ? 15 : 4, side ? 4 : 15), fill: side ? BLUE : "#fff", stroke: INK, "stroke-width": 2.5 }, g); + const dots = []; + if (ls) ls.forEach((lw, j) => el("rect", { x: 20, y: (ls.length > 1 ? 15 + j * 15 : 14.5), width: lw, height: 7, rx: 3.5, fill: side ? "#fff" : GREY }, g)); + else [24, 39, 54].forEach((cx) => dots.push(el("circle", { cx, cy: 18, r: 4.5, fill: MUTE }, g))); + return { g, x, y, w, h, side, dots, at: 0.9 + i * 0.82 }; + }); + + /* right: tiles around a shared hive */ + const HC = [576, 330]; + const TILES = [["Claude Code", BLUE, 448, 196], ["Codex", LIL, 704, 196], ["Gemini CLI", MINT, 448, 466], ["OpenCode", SKY, 704, 466], ["Qwen Code", YEL, 576, 116]]; + const links = TILES.map(([, , x, y]) => el("path", { d: `M${x} ${y} L${HC[0]} ${HC[1]}`, stroke: GREY, "stroke-width": 3, "stroke-dasharray": "2 9", "stroke-linecap": "round" }, svg)); + const envL = el("g", {}, svg); + const slot = el("g", {}, svg); + el("rect", { x: -70, y: -33, width: 140, height: 66, rx: 12, fill: "#fff", stroke: MUTE, "stroke-width": 2, "stroke-dasharray": "6 6" }, slot); + el("path", { d: "M-10 0 H10 M0 -10 V10", stroke: MUTE, "stroke-width": 3, "stroke-linecap": "round" }, slot); + tf(slot, 576, 116); + const burst = el("rect", { x: -70, y: -33, width: 140, height: 66, rx: 12, fill: "none", stroke: YEL, "stroke-width": 5, opacity: 0 }, svg); + const tiles = TILES.map(([name, col, x, y]) => { + const g = el("g", {}, svg), body = el("g", {}, g); + el("rect", { x: -70, y: -33, width: 140, height: 66, rx: 12, fill: "#fff" }, body); + el("path", { d: "M-70 -15 V-21 A12 12 0 0 1 -58 -33 H58 A12 12 0 0 1 70 -21 V-15 Z", fill: col }, body); + el("path", { d: "M-70 -15 H70", stroke: INK, "stroke-width": 2 }, body); + [-56, -47, -38].forEach((cx) => el("circle", { cx, cy: -24, r: 3, fill: "#fff", stroke: INK, "stroke-width": 1.5 }, body)); + el("rect", { x: -70, y: -33, width: 140, height: 66, rx: 12, fill: "none", stroke: INK, "stroke-width": 2.5 }, body); + el("path", { d: "M-56 3 L-50 9 L-56 15", stroke: INK, "stroke-width": 2.5, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, body); + fit(txt(body, -42, 15, name, { "font-size": 17, "font-weight": 700 }), 104); + return { g, body, x, y, col }; + }); + const hive = el("g", {}, svg), hHalo = el("rect", { x: -86, y: -41, width: 172, height: 82, rx: 22, fill: "none", stroke: YEL, "stroke-width": 6, opacity: 0 }, hive); + const hBody = el("g", {}, hive); + el("rect", { x: -86, y: -41, width: 172, height: 82, rx: 22, fill: YEL, stroke: INK, "stroke-width": 3 }, hBody); + const hx = (cx, cy, r) => "M" + [0, 1, 2, 3, 4, 5].map((k) => { const a = Math.PI / 6 + (k * Math.PI) / 3; return `${(cx + r * Math.cos(a)).toFixed(2)} ${(cy + r * Math.sin(a)).toFixed(2)}`; }).join(" L") + " Z"; + [-20, 0, 20].forEach((cx) => el("path", { d: hx(cx, -16, 8), fill: "#fff", stroke: INK, "stroke-width": 2, "stroke-linejoin": "round" }, hBody)); + txt(hBody, 0, 22, "Shared hive", { "text-anchor": "middle", "font-size": 21, "font-weight": 700 }); + + /* envelopes: tile to hive, a beat inside, hive to tile */ + const mkEnv = () => { + const g = el("g", { opacity: 0 }, envL); + const body = el("rect", { x: -13, y: -9, width: 26, height: 18, rx: 3, fill: "#fff", stroke: INK, "stroke-width": 2 }, g); + el("path", { d: "M-13 -9 L0 1.5 L13 -9", stroke: INK, "stroke-width": 2, fill: "none", "stroke-linejoin": "round" }, g); + return { g, body }; + }; + const AUTO = [[2.5, 0, 2], [3.2, 1, 3], [3.9, 3, 0], [4.6, 2, 1], [5.3, 0, 3], [6.0, 1, 2], [6.7, 3, 1], [7.95, 2, 4], [8.9, 4, 0], [9.6, 1, 3], [10.3, 3, 4], [10.8, 2, 1], [11.5, 0, 3], [12.575, 4, 2]] + .map(([at, from, to]) => Object.assign(mkEnv(), { at, from, to })); + const pool = [0, 1, 2, 3].map(mkEnv); + const FLY = 1.9, IN = 0.85, OUT = 1.05; + const place = (E, u) => { + if (u < 0 || u > FLY || (u >= IN && u < OUT)) { E.g.setAttribute("opacity", 0); return; } + const A = u < IN ? [tiles[E.from].x, tiles[E.from].y] : HC, B = u < IN ? HC : [tiles[E.to].x, tiles[E.to].y]; + const q = u < IN ? u / IN : (u - OUT) / (FLY - OUT), p = 0.75 * q + 0.25 * io(q); + const x = A[0] + (B[0] - A[0]) * p, y = A[1] + (B[1] - A[1]) * p; + tf(E.g, x, y, 0.85 + 0.2 * Math.sin(Math.PI * p), 8 * Math.sin(Math.PI * 2 * p)); + E.body.setAttribute("fill", tiles[E.from].col); + E.g.setAttribute("opacity", 1); + }; + + const btn = mkBtn(svg, "Add an agent"); + let qAt = null, bounceAt = -99, press = -99, manual = [], lastT = 0; + button(btn.g, "Add an agent", () => { + const now = api.now(); + if (qAt === null && lastT < ADD) qAt = now; else bounceAt = now; + const E = pool[manual.length % pool.length]; + manual.push(Object.assign(E, { at: now, from: [1, 3, 0, 2][manual.length % 4], to: 4 })); + if (manual.length > pool.length) manual = manual.slice(-pool.length); + press = now; api.poke(); + }); + + return (t, now) => { + if (t < lastT - 0.5) { qAt = null; bounceAt = -99; manual = []; pool.forEach((E) => E.g.setAttribute("opacity", 0)); } + lastT = t; + const calm = api.calm; + [tL, tR].forEach((n, i) => { const k = seg(t, 0.15 + i * 0.1, 0.6 + i * 0.1); n.setAttribute("opacity", seg(k, 0, 0.5)); n.setAttribute("transform", `translate(0 ${14 * (1 - oc(k))})`); }); + hintT.setAttribute("opacity", seg(t, 2.0, 2.4)); + const ck = seg(t, 0.25, 0.75); colG.setAttribute("opacity", seg(ck, 0, 0.5)); colG.setAttribute("transform", `translate(0 ${20 * (1 - oc(ck))})`); + lab.setAttribute("opacity", seg(t, 0.5, 0.9)); + const settle = seg(t, 7.4, 7.9); + bubbles.forEach((B) => { + const k = seg(t, B.at, B.at + 0.5), e = ob(k), ox = B.side ? B.w : 0; + B.g.setAttribute("transform", `translate(${B.x + ox} ${B.y + B.h + 16 * (1 - oc(k))}) scale(${0.6 + 0.4 * e}) translate(${-ox} ${-B.h})`); + B.g.setAttribute("opacity", seg(k, 0, 0.3)); + B.dots.forEach((d, j) => { const p = bump(t, END, 1.3, j * 0.16, 0.42) * settle; d.setAttribute("cy", 18 - 3.5 * p); d.setAttribute("opacity", 0.55 + 0.45 * p); }); + }); + + /* Qwen Code: added by the autoplay at ADD, or by a tap */ + const qk = qAt !== null ? (calm ? 1 : seg(now - qAt, 0, 0.5)) : seg(t, ADD, ADD + 0.5); + const bk = calm ? 0 : seg(now - bounceAt, 0, 0.45); + const live = (i) => (i < 4 ? 1 : qk); + links.forEach((n, i) => n.setAttribute("opacity", i < 4 ? seg(t, 1.35 + i * 0.09, 1.7 + i * 0.09) : seg(qk, 0.4, 1))); + slot.setAttribute("opacity", seg(t, 1.7, 2.1) * (1 - seg(qk, 0, 0.35))); + const pk = qAt !== null ? (calm ? 1 : seg(now - qAt, 0.05, 0.6)) : seg(t, ADD + 0.05, ADD + 0.6); + const burstK = Math.max(pk < 1 ? pk : 0, bk < 1 ? bk : 0); + burst.setAttribute("transform", `translate(576 116) scale(${1 + 0.35 * burstK})`); burst.setAttribute("opacity", burstK > 0 && burstK < 1 ? 1 - burstK : 0); + + /* arrivals make the receiving tile hop, entries make the hive glow */ + const hop = [0, 0, 0, 0, 0]; let glow = 0; + const note = (E, u) => { + const a = seg(u, FLY, FLY + 0.4); if (a > 0 && a < 1) hop[E.to] = Math.max(hop[E.to], Math.sin(Math.PI * a)); + const g = seg(u, IN, IN + 0.55); if (g > 0 && g < 1) glow = Math.max(glow, 1 - g); + }; + AUTO.forEach((E) => { + const u = t - E.at, ok = live(E.from) >= 1 && (u < OUT || live(E.to) >= 1); + if (!ok) { E.g.setAttribute("opacity", 0); return; } + place(E, u); note(E, u); + }); + manual.forEach((E) => { if (calm) return E.g.setAttribute("opacity", 0); const u = now - E.at; place(E, u); note(E, u); }); + tiles.forEach((T, i) => { + if (i < 4) { + const k = seg(t, 1.0 + i * 0.1, 1.5 + i * 0.1); + tf(T.g, T.x, T.y + 16 * (1 - oc(k)), (0.6 + 0.4 * ob(k)) * (1 + 0.07 * hop[i])); + T.g.setAttribute("opacity", seg(k, 0, 0.3)); + } else { + tf(T.g, T.x, T.y, (0.3 + 0.7 * ob(qk)) * (1 + 0.07 * hop[i] + 0.12 * dip(bk))); + T.g.setAttribute("opacity", seg(qk, 0, 0.25)); + } + }); + const he = seg(t, 0.8, 1.3); + tf(hive, HC[0], HC[1], (0.5 + 0.5 * ob(he)) * (1 + 0.035 * glow)); + hive.setAttribute("opacity", seg(he, 0, 0.3)); + hHalo.setAttribute("opacity", glow * 0.9); hHalo.setAttribute("transform", `scale(${1 + 0.12 * (1 - glow)} ${1 + 0.3 * (1 - glow)})`); + + const be = seg(t, 1.9, 2.35), pd = calm ? 0 : dip(seg(now, press, press + 0.3)); + tf(btn.g, 576, 560 + 14 * (1 - ob(be))); btn.g.setAttribute("opacity", seg(be, 0, 0.4)); + btn.body.setAttribute("transform", `translate(0 ${3 * pd}) scale(${1 - 0.05 * pd})`); + }; + }, + }; + + /* ---- 2. two belts: a gate for your approval, and an orchestrator with an approvals queue and a breaker ---- */ + SC.leash = { + dur: 14, still: 14, + build(svg, api) { + const END = 14, GX = 600, Y1 = 111, Y2 = 453, V1 = 250, V2 = 230, LOOP = 7.8, LIFT = 465, X0 = 46; + const tTop = txt(svg, 40, 50, "Claude Duet", { "font-size": 24, "font-weight": 700 }); + const gLab = txt(svg, GX, 50, "Your approval", { "text-anchor": "middle", "font-size": 17, "font-weight": 700 }); + const divider = el("path", { d: "M40 186 H760", stroke: SOFT, "stroke-width": 3, "stroke-linecap": "round" }, svg); + const tBot = txt(svg, 40, 232, "Munder Difflin", { "font-size": 24, "font-weight": 700 }); + + const belt = (x0, x1, y) => { + const g = el("g", {}, svg); + el("rect", { x: x0, y, width: x1 - x0, height: 20, rx: 10, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, g); + const run = el("path", { d: `M${x0 + 20} ${y + 10} H${x1 - 20}`, stroke: GREY, "stroke-width": 3, "stroke-dasharray": "10 12", "stroke-linecap": "round" }, g); + const rollers = [x0 + 10, x1 - 10].map((x) => { const r = el("g", {}, g); el("circle", { r: 6, fill: "#fff", stroke: INK, "stroke-width": 2 }, r); el("path", { d: "M-4 0 H4", stroke: INK, "stroke-width": 2, "stroke-linecap": "round" }, r); return { r, x, y: y + 10 }; }); + return { g, run, rollers }; + }; + const b1 = belt(40, 770, 128), b2 = belt(40, 700, 470); + + /* tray, lift lane and breaker sit behind the tokens */ + const tray = el("g", {}, svg); + el("rect", { x: 352, y: 248, width: 226, height: 164, rx: 18, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, tray); + txt(tray, LIFT, 277, "Approvals queue", { "text-anchor": "middle", "font-size": 17, "font-weight": 700 }); + const lane = el("path", { d: `M${LIFT - 8} 432 L${LIFT} 424 L${LIFT + 8} 432 M${LIFT - 8} 422 L${LIFT} 414 L${LIFT + 8} 422`, stroke: GREY, "stroke-width": 3, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }, svg); + + const brk = el("g", {}, svg); + txt(brk, 770, 268, "Circuit breaker", { "text-anchor": "end", "font-size": 17, "font-weight": 700 }); + el("rect", { x: 744, y: 408, width: 8, height: 82, fill: INK }, brk); + el("rect", { x: 730, y: 486, width: 36, height: 8, rx: 4, fill: INK }, brk); + el("rect", { x: 728, y: 284, width: 40, height: 126, rx: 12, fill: INK }, brk); + const LAMPS = [["Steer", YEL, 314], ["Constrain", LIL, 347], ["Stop", BLUE, 380]].map(([s, c, y]) => { + const halo = el("circle", { cx: 748, cy: y, r: 12, fill: "none", stroke: c, "stroke-width": 4, opacity: 0 }, brk); + const lamp = el("circle", { cx: 748, cy: y, r: 11, fill: DIM, stroke: INK, "stroke-width": 2 }, brk); + const lb = txt(brk, 716, y + 6, s, { "text-anchor": "end", "font-size": 16, "font-weight": 700, fill: MUTE }); + return { halo, lamp, lb, c }; + }); + + /* the gate: rails and bar behind the tokens so open passes stay readable */ + const gate = el("g", {}, svg); + el("path", { d: `M${GX - 13} 74 V126 M${GX + 13} 74 V126`, stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round" }, gate); + const bar = el("g", {}, gate); + el("rect", { x: GX - 8, y: 74, width: 16, height: 52, rx: 4, fill: LIL, stroke: INK, "stroke-width": 2.5 }, bar); + el("path", { d: `M${GX - 8} 91 H${GX + 8} M${GX - 8} 108 H${GX + 8}`, stroke: INK, "stroke-width": 2 }, bar); + el("rect", { x: GX - 19, y: 62, width: 38, height: 13, rx: 4, fill: INK }, gate); + + /* the orchestrator: a box on two legs over the bottom belt, so tokens ride through under it */ + const orch = el("g", {}, svg); + [176, 296].forEach((x) => el("rect", { x, y: 426, width: 8, height: 46, rx: 2, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, orch)); + el("path", { d: "M240 390 V379", stroke: INK, "stroke-width": 2.5 }, orch); + const ant = el("circle", { cx: 240, cy: 373, r: 7, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, orch); + el("rect", { x: 166, y: 390, width: 148, height: 42, rx: 13, fill: BLUE, stroke: INK, "stroke-width": 3 }, orch); + fit(txt(orch, 240, 417, "Orchestrator", { "text-anchor": "middle", "font-size": 17, "font-weight": 700 }), 128); + + /* tokens */ + const topL = el("g", {}, svg), botL = el("g", {}, svg); + const FREE = ["Read", "Draft", "Prepare"].map((s, i) => Object.assign(token(topL, s, MINT), { s0: 0.9 + i * 0.7 })); + const GATED = ["Send", "Pay", "Sign", "Delete"].map((s, i) => Object.assign(token(topL, s, YEL), { s0: 3.0 + i * 0.65 })); + { let edge = GX - 17; GATED.forEach((k) => { k.stop = edge - k.w / 2; edge -= k.w + 8; }); } + const SLOT = [311, 348, 385]; + let ri = 0; + const BOT = [["Routine request", 0.9], ["Spend", 1.55], ["Routine request", 2.55], ["Scope change", 3.45], ["Routine request", 4.45], ["Destructive step", 5.5], ["Routine request", 12.09]].map(([s, s0]) => { + const risky = s !== "Routine request"; + const k = Object.assign(token(botL, s, risky ? YEL : MINT), { s0, risky }); + if (risky) k.slot = ri++; + return k; + }); + const loopT = token(botL, "Looping agent", "#fff", LIL); + + /* controls */ + const btn = mkBtn(svg, "Send a looping agent", 17); + const hintT = hint(svg, "Tap to send a looping agent", 40, 344); + hintT.setAttribute("text-anchor", "start"); + let press = -99; + button(btn.g, "Send a looping agent", () => { press = api.now(); api.seek(LOOP - 0.05); }); + + /* the loop: start on the lower run at x 400, go left, up, along the top, down past the breaker, and back */ + const R = 40, LL = 880 + 2 * Math.PI * R, D = 3 * LL, APEX = 680 + 1.5 * Math.PI * R, RUN = 4.3, PW = 1.8; + const P = (s) => { + s = ((s % LL) + LL) % LL; + if (s < 240) return [400 - s, 533]; + s -= 240; if (s < Math.PI * R) { const a = Math.PI / 2 + s / R; return [160 + R * Math.cos(a), 493 + R * Math.sin(a)]; } + s -= Math.PI * R; if (s < 440) return [160 + s, 453]; + s -= 440; if (s < Math.PI * R) { const a = -Math.PI / 2 + s / R; return [600 + R * Math.cos(a), 493 + R * Math.sin(a)]; } + s -= Math.PI * R; return [600 - s, 533]; + }; + const LAMP_AT = [0, 1, 2].map((k) => 0.35 + RUN * (1 - Math.pow(1 - (APEX + k * LL) / D, 1 / PW))); + + const ride = (t, s, x0, x1, v) => { + const TAU = 0.35, dd = (v * TAU) / 2, T1 = (x1 - x0 - dd) / v, dt = t - s; + if (dt < T1) return [x0 + v * dt, s + T1 + TAU]; + const k = cl((dt - T1) / TAU); + return [x1 - dd * (1 - k) * (1 - k), s + T1 + TAU]; + }; + /* a token drops onto the start of a belt with a pop, then rides; returns its left edge offset */ + const spawn = (k, t, y) => { const e = seg(t, k.s0 - 0.25, k.s0); return { s: 0.4 + 0.6 * ob(e), dy: -22 * (1 - oc(e)), op: seg(e, 0, 0.4) }; }; + const sq = (k, n) => { const q = squash(k); n.inner.setAttribute("transform", `translate(0 ${n.h / 2}) scale(${1 + q} ${1 - q}) translate(0 ${-n.h / 2})`); }; + + return (t, now) => { + const calm = api.calm; + [tTop, gLab, tBot].forEach((n, i) => n.setAttribute("opacity", seg(t, 0.15 + i * 0.1, 0.5 + i * 0.1))); + divider.setAttribute("opacity", seg(t, 0.3, 0.6)); + [b1, b2].forEach((b, i) => { + const k = seg(t, 0.2 + i * 0.12, 0.75 + i * 0.12); + b.g.setAttribute("opacity", seg(k, 0, 0.4)); b.g.setAttribute("transform", `translate(${-30 * (1 - oc(k))} 0)`); + const v = i ? V2 : V1; + b.run.setAttribute("stroke-dashoffset", -((t * v) % 22)); + b.rollers.forEach((r) => tf(r.r, r.x, r.y, 1, ((t * v) / 6) * (180 / Math.PI))); + }); + const ge = seg(t, 0.4, 0.8); gate.setAttribute("opacity", seg(ge, 0, 0.4)); gate.setAttribute("transform", `translate(0 ${-14 * (1 - oc(ge))})`); + const shut = ob(seg(t, 4.65, 5.0)), hgt = 0.12 + 0.88 * shut; + bar.setAttribute("transform", `translate(0 74) scale(1 ${hgt}) translate(0 -74)`); + const te = seg(t, 0.55, 1.0); tray.setAttribute("opacity", seg(te, 0, 0.4)); tray.setAttribute("transform", `translate(0 ${16 * (1 - oc(te))})`); + lane.setAttribute("opacity", seg(t, 0.9, 1.2)); + const ke = seg(t, 0.65, 1.1); brk.setAttribute("opacity", seg(ke, 0, 0.4)); brk.setAttribute("transform", `translate(${20 * (1 - oc(ke))} 0)`); + const oe = seg(t, 0.5, 0.95); orch.setAttribute("opacity", seg(oe, 0, 0.3)); orch.setAttribute("transform", `translate(240 448) scale(${0.7 + 0.3 * ob(oe)}) translate(-240 -448)`); + const be = seg(t, 1.0, 1.45), pd = calm ? 0 : dip(seg(now, press, press + 0.3)); + tf(btn.g, 40 + btn.w / 2, 292 + 14 * (1 - ob(be))); btn.g.setAttribute("opacity", seg(be, 0, 0.4)); + btn.body.setAttribute("transform", `translate(0 ${3 * pd}) scale(${1 - 0.05 * pd})`); + hintT.setAttribute("opacity", seg(t, 1.3, 1.7)); + + /* top belt */ + FREE.forEach((k) => { + const x = X0 + k.w / 2 + V1 * Math.max(0, t - k.s0), sp = spawn(k, t), off = seg(x, 712, 768); + tf(k.g, x, Y1 + sp.dy + 20 * off * off, sp.s, 14 * off * off); k.g.setAttribute("opacity", sp.op * (1 - off)); + }); + const wait = seg(t, 6.6, 7.0); + GATED.forEach((k, i) => { + const [x, ta] = ride(Math.max(t, k.s0), k.s0, X0 + k.w / 2, k.stop, V1), sp = spawn(k, t); + const nud = bump(t, END, 2.4, i * 0.12, 0.35) * wait; + tf(k.g, x + 4 * nud, Y1 + sp.dy, sp.s); k.g.setAttribute("opacity", sp.op); + sq(seg(t, ta, ta + 0.4), k); + }); + + /* bottom belt */ + let antC = null; + BOT.forEach((k) => { + const x0 = X0 + k.w / 2, xl = x0 + V2 * Math.max(0, t - k.s0), sp = spawn(k, t); + if (t < k.s0 - 0.25) { k.g.setAttribute("opacity", 0); return; } + if (!k.risky) { + const off = seg(xl, 590, 650); + tf(k.g, xl, Y2 + sp.dy, sp.s * (1 - 0.12 * off)); k.g.setAttribute("opacity", sp.op * (1 - off)); + if (xl > 166 && xl < 314) antC = MINT; + return; + } + const ta = k.s0 + (LIFT - x0) / V2, lk = seg(t, ta, ta + 0.6); + const x = Math.min(xl, LIFT), y = Y2 + sp.dy + (SLOT[k.slot] - Y2) * ob(lk); + tf(k.g, x, y, sp.s); k.g.setAttribute("opacity", sp.op); + sq(seg(t, ta + 0.45, ta + 0.85), k); + if (x > 166 && x < 314) antC = YEL; + }); + ant.setAttribute("fill", antC || "#fff"); + + /* the looping agent and the breaker */ + const r = t - LOOP; + const mv = seg(r, 0.35, 0.35 + RUN), d = D * (1 - Math.pow(1 - mv, PW)); + const [lx, ly] = P(d); + const pop = ob(seg(r, 0, 0.35)), park = seg(r, 0.35 + RUN, 0.35 + RUN + 0.45); + tf(loopT.g, lx, ly, r < 0 ? 0 : 0.3 + 0.7 * pop); loopT.g.setAttribute("opacity", r < 0 ? 0 : 1); + sq(park, loopT); + loopT.d.setAttribute("fill", mix(LIL, BLUE, park)); + LAMPS.forEach((L, k) => { + const on = seg(r, LAMP_AT[k], LAMP_AT[k] + 0.2), fl = seg(r, LAMP_AT[k], LAMP_AT[k] + 0.6); + const pulse = bump(t, END, 2.2, k * 0.25, 0.5) * seg(r, LAMP_AT[2] + 0.8, LAMP_AT[2] + 1.2); + L.lamp.setAttribute("fill", mix(DIM, L.c, on)); + L.lb.setAttribute("fill", mix(MUTE, INK, on)); + const ho = fl > 0 && fl < 1 ? 1 - fl : 0.6 * pulse; + L.halo.setAttribute("r", fl > 0 && fl < 1 ? 12 + 14 * oc(fl) : 12 + 6 * pulse); L.halo.setAttribute("opacity", ho); + }); + }; + }, + }; + + /* ---- 3. one shared plan bar against per agent budgets ---- */ + SC.limits = { + dur: 12, still: 12, + build(svg, api) { + const END = 12; + const hintT = hint(svg, "Drag the slider", 756, 50); + const pL = el("rect", { x: 40, y: 78, width: 300, height: 440, rx: 24, fill: SOFT }, svg); + const pR = el("rect", { x: 368, y: 78, width: 392, height: 440, rx: 24, fill: SOFT }, svg); + const tL = txt(svg, 64, 122, "Claude Duet", { "font-size": 26, "font-weight": 700 }); + const tR = txt(svg, 392, 122, "Munder Difflin", { "font-size": 26, "font-weight": 700 }); + + /* left: one plan bar */ + const planL = txt(svg, 64, 196, "Your Claude plan", { "font-size": 20, "font-weight": 700 }); + const B = { x: 64, y: 214, w: 252, h: 84 }; + const defs = el("defs", {}, svg), cp = el("clipPath", { id: "mg-dvm-plan" }, defs); + el("rect", { x: B.x, y: B.y, width: B.w, height: B.h, rx: 20 }, cp); + const track = el("rect", { x: B.x, y: B.y, width: B.w, height: B.h, rx: 20, fill: "#fff" }, svg); + const fillG = el("g", { "clip-path": "url(#mg-dvm-plan)" }, svg); + const CH = 0.32 * B.w, BC = 0.05 * B.w, LT = 0.24 * B.w; + const chats = el("rect", { x: B.x - 3, y: B.y - 3, width: 0, height: B.h + 6, fill: BLUE, stroke: INK, "stroke-width": 2.5 }, fillG); + const checks = [0, 1, 2, 3].map((i) => el("rect", { x: B.x + CH + i * BC, y: B.y - 3, width: BC, height: B.h + 6, fill: SKY, stroke: INK, "stroke-width": 2.5 }, fillG)); + const large = el("rect", { x: 0, y: B.y - 3, width: LT, height: B.h + 6, fill: LIL, stroke: INK, "stroke-width": 2.5 }, fillG); + const cps = el("clipPath", { id: "mg-dvm-shine" }, defs), shineClip = el("rect", { x: B.x, y: B.y, width: 0, height: B.h }, cps); + const shine = el("path", { d: `M0 ${B.y} h22 l-30 ${B.h} h-22 Z`, fill: "#fff", opacity: 0, "clip-path": "url(#mg-dvm-shine)" }, svg); + const outline = el("rect", { x: B.x, y: B.y, width: B.w, height: B.h, rx: 20, fill: "none", stroke: INK, "stroke-width": 3 }, svg); + const legend = [[BLUE, "Chats"], [SKY, "Background checks"], [LIL, "Large tasks"]].map(([c, s], i) => { + const g = el("g", {}, svg); + el("rect", { x: 64, y: 337 + i * 46, width: 24, height: 24, rx: 7, fill: c, stroke: INK, "stroke-width": 2.5 }, g); + fit(txt(g, 102, 356 + i * 46, s, { "font-size": 21, "font-weight": 600 }), 214); + return g; + }); + + /* right: a slider and four budget bars */ + const runL = txt(svg, 392, 176, "Agents running", { "font-size": 20, "font-weight": 700 }); + const A = 580, Bx = 728, SY = 170, STEP = (Bx - A) / 3; + const sl = el("g", {}, svg); + const hit = el("rect", { x: A - 26, y: SY - 26, width: Bx - A + 52, height: 52, fill: "#fff", "fill-opacity": 0 }, sl); + el("path", { d: `M${A} ${SY} H${Bx}`, stroke: INK, "stroke-width": 16, "stroke-linecap": "round" }, sl); + el("path", { d: `M${A} ${SY} H${Bx}`, stroke: "#fff", "stroke-width": 11, "stroke-linecap": "round" }, sl); + const sFill = el("path", { d: `M${A} ${SY} H${A}`, stroke: YEL, "stroke-width": 11, "stroke-linecap": "round" }, sl); + const nums = [1, 2, 3, 4].map((n, i) => txt(sl, A + i * STEP, SY + 39, String(n), { "text-anchor": "middle", "font-family": MONO, "font-size": 16, "font-weight": 700, fill: MUTE })); + const knob = el("g", { role: "slider", tabindex: "0", "aria-label": "Agents running", "aria-valuemin": 1, "aria-valuemax": 4, class: "mg-hit" }, sl); + el("circle", { r: 24, fill: "#fff", "fill-opacity": 0 }, knob); + el("circle", { r: 13, fill: "#fff", stroke: INK, "stroke-width": 3 }, knob); + el("circle", { r: 20, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 4", class: "mg-ring" }, knob); + + const ROW = [262, 338, 414, 490], X0 = 474, BW = 262; + const CAP = [0.78, 0.46, 0.86, 0.66], FIN = [0.6, 0.46, 0.55, 0.38], APPEAR = [1.0, 1.6, 2.0, 2.4]; + const rows = ROW.map((y, i) => { + const g = el("g", {}, svg); + txt(g, 392, y + 7, `Agent ${i + 1}`, { "font-size": 18, "font-weight": 700 }); + el("rect", { x: X0, y: y - 10, width: BW, height: 20, rx: 10, fill: "#fff" }, g); + const cpi = el("clipPath", { id: `mg-dvm-row${i}` }, defs); + el("rect", { x: X0, y: y - 10, width: BW, height: 20, rx: 10 }, cpi); + const bar = el("rect", { x: X0 - 3, y: y - 13, width: 0, height: 26, fill: MINT, stroke: INK, "stroke-width": 2, "clip-path": `url(#mg-dvm-row${i})` }, g); + el("rect", { x: X0, y: y - 10, width: BW, height: 20, rx: 10, fill: "none", stroke: INK, "stroke-width": 2.5 }, g); + const cx = X0 + CAP[i] * BW; + el("path", { d: `M${cx} ${y - 17} V${y + 17}`, stroke: INK, "stroke-width": 2.5, "stroke-dasharray": "4 4" }, g); + txt(g, cx, y - 22, "Budget", { "text-anchor": "middle", "font-size": 16, "font-weight": 600, fill: MUTE }); + const tip = el("circle", { cy: y, r: 4, fill: "#fff", stroke: INK, "stroke-width": 2, opacity: 0 }, g); + const flash = el("circle", { cx, cy: y, r: 8, fill: "none", stroke: YEL, "stroke-width": 4, opacity: 0 }, g); + return { g, bar, tip, flash, y, cx }; + }); + const tag = token(svg, "Stopped at budget", "#fff", YEL, 16, 28); + const HIT = 2.0 + CAP[1] / 0.16; + const fillAt = (i, t) => { + if (i === 1) return Math.min(CAP[1], 0.16 * Math.max(0, t - 2.0)); + const s = [1.6, 0, 2.4, 2.8][i], u = seg(t, s, 11.6); + return FIN[i] * (1 - Math.pow(1 - u, 1.5)); + }; + const muted = fit(txt(svg, 564, 558, "Uses the subscriptions you already pay for", { "text-anchor": "middle", "font-size": 19, "font-weight": 500, fill: MUTE }), 380); + + /* slider state: null follows the autoplay, a number is the viewer's own count */ + let user = null, lastT = 0, down = false, drag = null; + const vis = [0, 0, 0, 0].map(() => ({ on: false, at: -99 })); + const setN = (n, now) => { user = n; vis.forEach((v, i) => { const on = i < n; if (v.on !== on) { v.on = on; v.at = now; } }); api.poke(); }; + const first = () => { if (user === null) { const n = Math.round(1 + 3 * autoV(lastT)); vis.forEach((v, i) => { v.on = i < n; v.at = -99; }); user = n; } }; + const autoV = (t) => (io(seg(t, 1.3, 1.6)) + io(seg(t, 1.7, 2.0)) + io(seg(t, 2.1, 2.4))) / 3; + const fromX = (e) => { const r = svg.getBoundingClientRect(); return cl((((e.clientX - r.left) / r.width) * 800 - A) / (Bx - A)); }; + const dragTo = (e) => { first(); drag = fromX(e); setN(Math.round(1 + 3 * drag), api.now()); }; + [knob, hit].forEach((n) => n.addEventListener("pointerdown", (e) => { down = true; try { knob.setPointerCapture(e.pointerId); } catch (_) {} dragTo(e); knob.focus && knob.focus({ preventScroll: true }); })); + knob.addEventListener("pointermove", (e) => { if (down) dragTo(e); }); + const up = () => { down = false; drag = null; api.poke(); }; + knob.addEventListener("pointerup", up); knob.addEventListener("pointercancel", up); + knob.addEventListener("keydown", (e) => { + const k = e.key, cur = user === null ? Math.round(1 + 3 * autoV(lastT)) : user; + const n = k === "ArrowRight" || k === "ArrowUp" ? cur + 1 : k === "ArrowLeft" || k === "ArrowDown" ? cur - 1 : k === "Home" ? 1 : k === "End" ? 4 : null; + if (n === null) return; + e.preventDefault(); first(); setN(Math.max(1, Math.min(4, n)), api.now()); + }); + + return (t, now) => { + if (t < lastT - 0.5 && !down) user = null; + lastT = t; + const calm = api.calm; + [pL, pR].forEach((n, i) => { const k = seg(t, 0.1 + i * 0.1, 0.55 + i * 0.1); n.setAttribute("opacity", seg(k, 0, 0.4)); n.setAttribute("transform", `translate(0 ${18 * (1 - oc(k))})`); }); + [tL, tR].forEach((n, i) => n.setAttribute("opacity", seg(t, 0.3 + i * 0.1, 0.65 + i * 0.1))); + hintT.setAttribute("opacity", seg(t, 2.6, 3.0)); + planL.setAttribute("opacity", seg(t, 0.5, 0.85)); + const bk = seg(t, 0.6, 1.05); + [track, outline].forEach((n) => { n.setAttribute("opacity", seg(bk, 0, 0.4)); n.setAttribute("transform", `translate(${B.x} ${B.y + B.h / 2}) scale(${0.85 + 0.15 * ob(bk)}) translate(${-B.x} ${-B.y - B.h / 2})`); }); + fillG.setAttribute("opacity", bk > 0.5 ? 1 : 0); + legend.forEach((g, i) => { const k = seg(t, 0.8 + i * 0.09, 1.25 + i * 0.09); g.setAttribute("opacity", seg(k, 0, 0.4)); g.setAttribute("transform", `translate(${-16 * (1 - oc(k))} 0)`); }); + chats.setAttribute("width", 3 + CH * oc(seg(t, 1.2, 2.4))); + checks.forEach((c, i) => { const k = seg(t, 2.7 + i * 0.6, 3.15 + i * 0.6); c.setAttribute("transform", `translate(0 ${-(B.h + 10) * (1 - ob(k))})`); c.setAttribute("opacity", k > 0 ? 1 : 0); }); + const lk = seg(t, 5.3, 6.2), lx = B.x + CH + 4 * BC; + large.setAttribute("x", B.x + B.w + 6 + (lx - B.x - B.w - 6) * ob(lk)); + const filled = CH + 4 * BC + LT, sh = seg(t, 7.0, 7.4); + shineClip.setAttribute("width", filled); + const su = ((((t - END) / 3.2) % 1) + 1) % 1; + shine.setAttribute("transform", `translate(${B.x - 10 + su * 2 * (filled + 40)} 0)`); + shine.setAttribute("opacity", su < 0.5 ? 0.45 * Math.sin(su * 2 * Math.PI) * sh : 0); + + runL.setAttribute("opacity", seg(t, 0.6, 0.95)); + const se = seg(t, 0.7, 1.15); sl.setAttribute("opacity", seg(se, 0, 0.4)); sl.setAttribute("transform", `translate(${16 * (1 - oc(se))} 0)`); + const v = drag !== null ? drag : user !== null ? (user - 1) / 3 : autoV(t); + knob.setAttribute("transform", `translate(${A + (Bx - A) * v} ${SY})`); + sFill.setAttribute("d", `M${A} ${SY} H${A + (Bx - A) * v}`); + const n = user !== null ? user : Math.round(1 + 3 * v); + knob.setAttribute("aria-valuenow", n); + nums.forEach((m, i) => m.setAttribute("fill", i + 1 <= n ? INK : MUTE)); + + const live = seg(t, 6.0, 6.5); + rows.forEach((R, i) => { + let k; + if (user === null) k = ob(seg(t, APPEAR[i], APPEAR[i] + 0.45)); + else { const V = vis[i]; k = calm ? +V.on : V.on ? ob(seg(now - V.at, 0, 0.4)) : 1 - io(seg(now - V.at, 0, 0.3)); } + R.g.setAttribute("opacity", cl(k * 1.6)); + R.g.setAttribute("transform", `translate(${-24 * (1 - k)} 0)`); + const f = fillAt(i, t), stopped = i === 1 && t >= HIT; + const ov = i === 1 ? 0.012 * dip(seg(t, HIT, HIT + 0.3)) : 0; + R.bar.setAttribute("width", 3 + (f + ov) * BW); + R.bar.setAttribute("fill", stopped ? mix(MINT, YEL, seg(t, HIT, HIT + 0.25)) : MINT); + const fl = seg(t, HIT, HIT + 0.6); + R.flash.setAttribute("opacity", i === 1 && fl > 0 && fl < 1 ? 1 - fl : 0); R.flash.setAttribute("r", 8 + 18 * oc(fl)); + const p = bump(t, END, 1.6, i * 0.2, 0.5) * live; + R.tip.setAttribute("cx", X0 + f * BW); R.tip.setAttribute("r", 4 + 1.8 * p); + R.tip.setAttribute("opacity", i === 1 || f < 0.02 ? 0 : 1); + }); + const tk = seg(t, HIT + 0.15, HIT + 0.6), rk = user === null ? 1 : calm ? +vis[1].on : vis[1].on ? seg(now - vis[1].at, 0, 0.3) : 1 - seg(now - vis[1].at, 0, 0.2); + tf(tag.g, X0 + tag.w / 2, ROW[1] + 29 - 2 * bump(t, END, 2.4, 0.3, 0.5) * live, 0.4 + 0.6 * ob(tk), -8 * (1 - ob(tk))); + tag.g.setAttribute("opacity", seg(tk, 0, 0.3) * rk); + muted.setAttribute("opacity", seg(t, 2.8, 3.3)); + }; + }, + }; + + /* ---- player ---- */ + const q = new URLSearchParams(location.search), frozen = q.has("mgstill"), fixed = parseFloat(q.get("mgstill")); + const calm = matchMedia("(prefers-reduced-motion: reduce)").matches; + const mount = (fig) => { + const sc = SC[fig.dataset.scene], img = fig.querySelector("img"); if (!sc || !img) return; + const W = sc.w || 800, H = sc.h || 600; + const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "mg-svg", role: "group", "aria-label": img.alt || "" }); + svg.style.aspectRatio = `${W} / ${H}`; + el("rect", { width: W, height: H, fill: "#fff" }, svg); + img.replaceWith(svg); + let off = 0, raf = 0, seen = false, held = calm; + const base = performance.now(), clock = () => (performance.now() - base) / 1000; + let draw = () => {}; + const frame = () => { const now = clock(); draw(held ? sc.still : (((now + off) % sc.dur) + sc.dur) % sc.dur, now); }; + const tick = () => { cancelAnimationFrame(raf); frame(); if (seen && !held) raf = requestAnimationFrame(tick); }; + // with reduced motion a tap redraws the still frame with the new choice instead of starting playback + const api = { now: clock, calm, seek: (s) => { if (calm) return frame(); off = s - clock(); held = false; tick(); }, poke: () => { if (calm) return frame(); held = false; tick(); } }; + draw = sc.build(svg, api); + if (frozen) { draw(isNaN(fixed) ? sc.still : fixed, 0); return; } + new IntersectionObserver(([e]) => { + seen = e.isIntersecting; + if (seen) { tick(); if (!fig.dataset.sent && window.posthog) { fig.dataset.sent = 1; window.posthog.capture("blog_scene_view", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } } + else cancelAnimationFrame(raf); + }, { threshold: 0.25 }).observe(fig); + svg.addEventListener("click", () => { if (!fig.dataset.hit && window.posthog) { fig.dataset.hit = 1; window.posthog.capture("blog_scene_interact", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } }); + frame(); + }; + const go = () => document.querySelectorAll("figure.mg[data-scene]").forEach(mount); + const fonts = document.fonts && document.fonts.load ? Promise.all([document.fonts.load('700 32px "Space Grotesk"'), document.fonts.load('500 20px "Space Grotesk"'), document.fonts.load('700 20px "JetBrains Mono"')]).catch(() => {}) : Promise.resolve(); + const start = () => fonts.then(go, go); + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); +})(); diff --git a/blog/src/assets/media/claude-duet-vs-munder-difflin/team.png b/blog/src/assets/media/claude-duet-vs-munder-difflin/team.png new file mode 100644 index 0000000000..d4ef948e10 Binary files /dev/null and b/blog/src/assets/media/claude-duet-vs-munder-difflin/team.png differ diff --git a/blog/src/assets/media/claude-duet-waitlist/check.png b/blog/src/assets/media/claude-duet-waitlist/check.png new file mode 100644 index 0000000000..ee7af0da81 Binary files /dev/null and b/blog/src/assets/media/claude-duet-waitlist/check.png differ diff --git a/blog/src/assets/media/claude-duet-waitlist/hero.png b/blog/src/assets/media/claude-duet-waitlist/hero.png new file mode 100644 index 0000000000..d5f4e5fd99 Binary files /dev/null and b/blog/src/assets/media/claude-duet-waitlist/hero.png differ diff --git a/blog/src/assets/media/claude-duet-waitlist/meanwhile.png b/blog/src/assets/media/claude-duet-waitlist/meanwhile.png new file mode 100644 index 0000000000..efe395519f Binary files /dev/null and b/blog/src/assets/media/claude-duet-waitlist/meanwhile.png differ diff --git a/blog/src/assets/media/claude-duet-waitlist/motion.css b/blog/src/assets/media/claude-duet-waitlist/motion.css new file mode 100644 index 0000000000..5bcc554467 --- /dev/null +++ b/blog/src/assets/media/claude-duet-waitlist/motion.css @@ -0,0 +1,13 @@ +/* Bright coded scenes for "Claude Duet waitlist". Pairs with motion.js. */ +.prose figure.mg { margin: 42px 0; } +.prose figure.mg img, .mg .mg-svg { display: block; width: 100%; height: auto; border: 1px solid #1A1320; border-radius: 18px; background: #fff; } +.mg figcaption { font: 500 13px/1.5 "Space Grotesk", system-ui, sans-serif; color: #6B5878; padding-top: 10px; text-align: center; } +.mg .mg-svg { touch-action: manipulation; -webkit-tap-highlight-color: transparent; } +.mg .mg-svg text { -webkit-user-select: none; user-select: none; pointer-events: none; } +.mg .mg-hit { cursor: pointer; outline: none; } +.mg [role="slider"] { cursor: grab; touch-action: none; } +.mg .mg-ring { stroke-opacity: 0; transition: stroke-opacity 0.15s; } +.mg .mg-hit:focus-visible .mg-ring { stroke-opacity: 1; } +.mg [role="slider"]:focus-visible circle:nth-child(2) { stroke-width: 5; } +@media (hover: hover) { .mg .mg-hit:hover .mg-ring { stroke-opacity: 0.5; } } +@media (prefers-reduced-motion: reduce) { .mg .mg-ring { transition: none; } } diff --git a/blog/src/assets/media/claude-duet-waitlist/motion.js b/blog/src/assets/media/claude-duet-waitlist/motion.js new file mode 100644 index 0000000000..bdcae9c356 --- /dev/null +++ b/blog/src/assets/media/claude-duet-waitlist/motion.js @@ -0,0 +1,633 @@ +/* Bright coded scenes for "Claude Duet waitlist". Vanilla JS, no library. +
holds a still image; this script swaps in a live SVG. + Plays only in view, honours reduced motion, "?mgstill" freezes each scene on its still frame. */ +(() => { + const NS = "http://www.w3.org/2000/svg"; + const INK = "#1A1320", YEL = "#FFCA54", BLUE = "#6C8EF5", LIL = "#B69CFF", MINT = "#7FD8AE", SKY = "#9AD8FF", SOFT = "#F1ECF9", MUTE = "#7A6A88"; + const SANS = '"Space Grotesk", system-ui, sans-serif', MONO = '"JetBrains Mono", ui-monospace, monospace'; + const el = (t, a = {}, p) => { const n = document.createElementNS(NS, t); for (const k in a) n.setAttribute(k, a[k]); if (p) p.appendChild(n); return n; }; + const txt = (p, x, y, s, a = {}) => { const n = el("text", Object.assign({ x, y, "font-family": SANS, "font-size": 24, "font-weight": 600, fill: INK }, a), p); n.textContent = s; return n; }; + const fit = (n, max) => { const w = n.getComputedTextLength(); if (w > max) n.setAttribute("font-size", (+n.getAttribute("font-size") * max) / w); return n; }; + const cl = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v)); + const seg = (t, a, b) => cl((t - a) / (b - a)); + const oc = (x) => 1 - Math.pow(1 - x, 3); + /* back out with an adjustable overshoot: c 1.7 is a lively arrival, c 0.8 a soft one */ + const bk = (x, c = 1.70158) => 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); + const io = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); + const dip = (k) => (k > 0 && k < 1 ? Math.sin(Math.PI * k) : 0); + /* a squash and stretch wobble after a landing: k runs 0 to 1 */ + const squash = (k) => (k > 0 && k < 1 ? Math.sin(k * Math.PI * 2) * (1 - k) * 0.14 : 0); + /* a decaying wobble: k runs 0 to 1, n swings */ + const wob = (k, n = 3) => (k > 0 && k < 1 ? Math.sin(k * Math.PI * 2 * n) * Math.pow(1 - k, 2) : 0); + /* a soft idle beat: 0 at rest, a smooth hump for the first w of each period P; always at rest when t = end (for d < P * (1 - w)) */ + const bump = (t, end, P, d = 0, w = 0.5) => { if (!isFinite(t)) return 0; const u = ((((t - end - d) / P) % 1) + 1) % 1; return u < w ? Math.sin((Math.PI * u) / w) : 0; }; + const tf = (n, x, y, s = 1, r = 0) => n.setAttribute("transform", `translate(${x} ${y}) rotate(${r}) scale(${s})`); + const hex = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + const mix = (a, b, k) => { const A = hex(a), B = hex(b); return "#" + A.map((v, i) => Math.round(v + (B[i] - v) * cl(k)).toString(16).padStart(2, "0")).join(""); }; + const rise = (n, k, d = 16) => { n.setAttribute("transform", `translate(0 ${d * (1 - oc(k))})`); n.setAttribute("opacity", seg(k, 0, 0.4)); }; + /* scale a node about a point */ + const about = (n, x, y, sx, sy = sx, r = 0) => n.setAttribute("transform", `translate(${x} ${y}) rotate(${r}) scale(${sx} ${sy}) translate(${-x} ${-y})`); + const YEL_T = mix(YEL, "#ffffff", 0.55), SKY_T = mix(SKY, "#ffffff", 0.55), LIL_T = mix(LIL, "#ffffff", 0.6), MINT_T = mix(MINT, "#ffffff", 0.5); + const button = (g, label, fn) => { + g.setAttribute("role", "button"); g.setAttribute("tabindex", "0"); g.setAttribute("aria-label", label); g.classList.add("mg-hit"); + g.addEventListener("click", fn); + g.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); } }); + }; + const hint = (svg, s, x = 756, y = 50) => txt(svg, x, y, s, { "text-anchor": "end", "font-size": 16, fill: MUTE, "font-weight": 500 }); + /* draw a stroke on: n gets a dash as long as len, k runs 0 to 1 */ + const draw = (n, len, k) => { n.setAttribute("stroke-dasharray", `${len} ${len + 40}`); n.setAttribute("stroke-dashoffset", len * (1 - k)); n.setAttribute("opacity", k > 0 ? 1 : 0); }; + + const SC = {}; + + /* ---- title card, 16 by 9: sealed envelopes ride a rail; one lifts out and opens ---- */ + SC.hero = { + w: 960, h: 540, dur: 11, still: 11, + build(svg) { + const END = 11; + const pill = el("g", {}, svg); + const pr = el("rect", { y: -20, height: 40, rx: 20, fill: YEL, stroke: INK, "stroke-width": 2.5 }, pill); + const pt = txt(pill, 0, 7, "Access", { "text-anchor": "middle", "font-size": 19, "font-weight": 700 }); + const pw = Math.ceil(pt.getComputedTextLength()) + 40; + pr.setAttribute("x", -pw / 2); pr.setAttribute("width", pw); + const l1g = el("g", {}, svg), l2g = el("g", {}, svg); + const l1 = txt(l1g, 60, 0, "Claude Duet", { "font-size": 80, "font-weight": 700 }); + const l2 = txt(l2g, 60, 0, "waitlist", { "font-size": 80, "font-weight": 700 }); + const TW = 450, tmax = Math.max(l1.getComputedTextLength(), l2.getComputedTextLength()); + if (tmax > TW) [l1, l2].forEach((n) => n.setAttribute("font-size", (80 * TW) / tmax)); + const FS = +l1.getAttribute("font-size"), Y1 = 204, Y2 = Math.round(Y1 + FS * 1.1); + l1.setAttribute("y", Y1); l2.setAttribute("y", Y2); + const UL = Math.round(l2.getComputedTextLength() - 6), UY = Y2 + 22; + const under = el("path", { d: `M66 ${UY} H${66 + UL}`, stroke: YEL, "stroke-width": 13, "stroke-linecap": "round", fill: "none" }, svg); + const s1g = el("g", {}, svg), s2g = el("g", {}, svg); + const s1 = txt(s1g, 63, UY + 56, "Who gets in first,", { "font-size": 26, "font-weight": 500, fill: MUTE }); + const s2 = txt(s2g, 63, UY + 90, "and what to use meanwhile", { "font-size": 26, "font-weight": 500, fill: MUTE }); + const smax = Math.max(s1.getComputedTextLength(), s2.getComputedTextLength()); + if (smax > 440) [s1, s2].forEach((n) => n.setAttribute("font-size", (26 * 440) / smax)); + const chkG = el("g", {}, svg); + txt(chkG, 64, 478, "Checked 22 Oct 2026", { "font-size": 18, "font-weight": 500, fill: MUTE }); + + /* the rail: a rounded bar on little wheels */ + const RX0 = 572, RX1 = 900, RY = 430, S = 84, EW = 66, EH = 43, BW = 190, BH = 124, K = EW / BW, LX = 740, LY = 296; + const G = S * 1.5, T0 = 5.8, P = 1.3; + const off = (t) => { + let o = G * oc(seg(t, 0.9, 3.0)); + if (t >= T0) { const v = (t - T0) / P, n = Math.floor(v), u = (v - n) * P; o += (S / 2) * (n + bk(seg(u, 0, 0.62), 1.3)); } + return o; + }; + const X0 = LX - G, ROWY = RY - 1 - EH / 2; + const railG = el("g", {}, svg); + const bar = el("rect", { x: RX0, y: RY, width: RX1 - RX0, height: 14, rx: 7, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, railG); + const wheels = []; + for (let x = RX0 + 30; x < RX1 - 10; x += 67) { + const w = el("g", {}, svg), spin = el("g", {}, w); + el("circle", { r: 11, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, spin); + el("path", { d: "M0 0 V-7 M0 0 L6.1 3.5 M0 0 L-6.1 3.5", stroke: INK, "stroke-width": 2.2, "stroke-linecap": "round" }, spin); + el("circle", { r: 2.4, fill: INK }, spin); + wheels.push({ w, spin, x }); + } + /* envelopes: body, folded pocket edge, flap, seal; the same shape at any size */ + const defs = el("defs", {}, svg), cp = el("clipPath", { id: "mg-wl-rail" }, defs); + el("rect", { x: RX0 - 18, y: RY - 140, width: RX1 - RX0 + 36, height: 141 }, cp); + const row = el("g", { "clip-path": "url(#mg-wl-rail)" }, svg); + const COLS = [SKY, LIL, YEL, BLUE, MINT]; + const shape = (p, W, H, fill, sw) => { + const inner = el("g", { transform: `translate(${-W / 2} ${-H})` }, p); + el("rect", { x: 0, y: 0, width: W, height: H, rx: W * 0.08, fill, stroke: INK, "stroke-width": sw }, inner); + el("path", { d: `M${W * 0.03} ${H * 0.36} L${W / 2} ${H * 0.64} L${W * 0.97} ${H * 0.36}`, fill: "none", stroke: INK, "stroke-width": sw * 0.8, "stroke-linejoin": "round", "stroke-linecap": "round" }, inner); + el("path", { d: `M${W * 0.02} ${H * 0.04} L${W / 2} ${H * 0.55} L${W * 0.98} ${H * 0.04}`, fill, stroke: INK, "stroke-width": sw, "stroke-linejoin": "round" }, inner); + el("circle", { cx: W / 2, cy: H * 0.55, r: H * 0.11, fill: "#fff", stroke: INK, "stroke-width": sw * 0.9 }, inner); + return inner; + }; + const pool = []; + for (let k = -6; k <= 5; k++) { + if (k === 0) continue; + const g = el("g", {}, row), body = el("g", {}, g); + shape(body, EW, EH, COLS[((k % 5) + 5) % 5], 2.5); + pool.push({ g, body, k, at: 0.95 + 0.085 * Math.max(0, k), hid: k < 0 }); + } + /* little housings at each end: envelopes come out of one and go into the other */ + const houses = [[RX0 - 18, 1], [RX1 + 18, -1]].map(([x, dir]) => { + const g = el("g", {}, svg); + el("rect", { x: -24, y: -62, width: 48, height: 70, rx: 12, fill: dir > 0 ? LIL : SKY, stroke: INK, "stroke-width": 2.5 }, g); + el("path", { d: `M${dir * 24} -50 V-4`, stroke: INK, "stroke-width": 5, "stroke-linecap": "round" }, g); + el("circle", { cx: -dir * 6, cy: -40, r: 5, fill: "#fff", stroke: INK, "stroke-width": 2.2 }, g); + return { g, x }; + }); + /* the chosen envelope, built large and shrunk into the row */ + const big = el("g", {}, svg), bigS = el("g", {}, big), bin = el("g", { transform: `translate(${-BW / 2} ${-BH})` }, bigS); + const SW = []; + const sw = (n, k = 1) => { SW.push([n, k]); return n; }; + sw(el("rect", { x: 0, y: 0, width: BW, height: BH, rx: BW * 0.08, fill: YEL, stroke: INK }, bin)); + const flapB = sw(el("path", { d: `M17 ${BH * 0.04} L${BW / 2} ${BH * 0.5} L${BW - 17} ${BH * 0.04} Z`, fill: YEL_T, stroke: INK, "stroke-linejoin": "round" }, bin)); + const ccp = el("clipPath", { id: "mg-wl-card" }, defs); + el("rect", { x: -20, y: -300, width: BW + 40, height: 300 + BH * 0.66 }, ccp); + const cardWrap = el("g", { "clip-path": "url(#mg-wl-card)" }, bin), card = el("g", {}, cardWrap); + sw(el("rect", { x: 15, y: 0, width: BW - 30, height: 200, rx: 12, fill: MINT, stroke: INK }, card)); + const cardT = txt(card, BW / 2, 47, "You're in", { "text-anchor": "middle", "font-size": 28, "font-weight": 700 }); + fit(cardT, BW - 54); + sw(el("path", { d: `M0 ${BH * 0.36} L${BW / 2} ${BH * 0.64} L${BW} ${BH * 0.36} V${BH - BW * 0.08} Q${BW} ${BH} ${BW * 0.92} ${BH} H${BW * 0.08} Q0 ${BH} 0 ${BH - BW * 0.08} Z`, fill: YEL, stroke: INK, "stroke-linejoin": "round" }, bin)); + const flapF = el("g", {}, bin); + sw(el("path", { d: `M${BW * 0.02} ${BH * 0.04} L${BW / 2} ${BH * 0.55} L${BW * 0.98} ${BH * 0.04}`, fill: YEL, stroke: INK, "stroke-linejoin": "round" }, flapF)); + const seal = el("g", {}, flapF); + sw(el("circle", { cx: BW / 2, cy: BH * 0.55, r: BH * 0.11, fill: "#fff", stroke: INK }, seal), 0.9); + /* sparkle strokes around the card */ + const LB = 362, CT = LB - BH - 82; + const SPK = [[-98, 14, -165], [-92, -14, -128], [98, 14, -15], [92, -14, -52], [0, -16, -90]]; + const spk = SPK.map(([x, y, a]) => { + const r = (a * Math.PI) / 180, dx = Math.cos(r) * 16, dy = Math.sin(r) * 16; + return el("path", { d: `M${LX + x} ${CT + y} l${dx} ${dy}`, stroke: INK, "stroke-width": 3.5, "stroke-linecap": "round", fill: "none" }, svg); + }); + + return (t) => { + /* type: pill, title lines, underline, subtitle, date */ + const a = bk(seg(t, 0.15, 0.6), 1.9); + tf(pill, 64 + pw / 2, 96, 0.6 + 0.4 * a); pill.setAttribute("opacity", seg(t, 0.15, 0.3)); + [l1g, l2g].forEach((g, i) => rise(g, seg(t, 0.3 + i * 0.1, 0.9 + i * 0.1), 28)); + draw(under, UL, oc(seg(t, 0.95, 1.5))); + [s1g, s2g].forEach((g, i) => rise(g, seg(t, 0.62 + i * 0.09, 1.2 + i * 0.09), 16)); + rise(chkG, seg(t, 0.95, 1.4), 10); + + /* rail grows from the left, wheels pop in turn */ + const rk = io(seg(t, 0.4, 1.05)); + about(railG, RX0, RY + 7, Math.max(0.01, rk), 1); railG.setAttribute("opacity", rk > 0 ? 1 : 0); + houses.forEach((h, i) => { const k = seg(t, 0.45 + i * 0.5, 0.85 + i * 0.5); tf(h.g, h.x, RY + 7, 0.4 + 0.6 * bk(k, 2)); h.g.setAttribute("opacity", seg(k, 0, 0.3)); }); + const o = off(t); + wheels.forEach((w, i) => { + const k = seg(t, 0.55 + i * 0.07, 0.95 + i * 0.07); + tf(w.w, w.x, RY + 27, bk(k, 2)); w.w.setAttribute("opacity", seg(k, 0, 0.3)); + w.spin.setAttribute("transform", `rotate(${(o / 11) * 57.3})`); + }); + + /* the row of sealed envelopes drops onto the rail and rides along */ + pool.forEach((e) => { + const x = X0 + e.k * S + o, d = e.hid ? (t >= 0.9 ? 1 : 0) : seg(t, e.at, e.at + 0.4); + tf(e.g, x, ROWY + EH / 2 - 54 * (1 - d * d)); + e.g.setAttribute("opacity", seg(d, 0, 0.3)); + const q = e.hid ? 0 : squash(seg(t, e.at + 0.4, e.at + 0.85)); + e.body.setAttribute("transform", `scale(${1 + q} ${1 - q})`); + }); + + /* the chosen one: drops in with the row, lifts out, swells, opens */ + const da = 0.95, d = seg(t, da, da + 0.4); + const lift = seg(t, 3.25, 4.05), le = bk(lift, 1.3), grow = oc(seg(t, 3.25, 3.95)); + const antic = dip(seg(t, 3.0, 3.3)); + const bx = X0 + (t < 3.25 ? o : G), rb = ROWY + EH / 2 - 54 * (1 - d * d) + 4 * antic; + const by = rb + (LB - rb) * le; + const s = K + (1 - K) * grow; + const settle = squash(seg(t, 5.12, 5.6)) * 0.5; + const q0 = squash(seg(t, da + 0.4, da + 0.85)); + big.setAttribute("transform", `translate(${bx} ${by}) rotate(${7 * wob(lift, 1)})`); + bigS.setAttribute("transform", `scale(${s * (1 + q0 + settle + 0.06 * antic)} ${s * (1 - q0 - settle - 0.08 * antic)})`); + big.setAttribute("opacity", seg(d, 0, 0.3)); + const swl = (2.5 + 0.6 * grow) / s; + SW.forEach(([n, k]) => { if (k) n.setAttribute("stroke-width", swl * k); }); + /* seal pops, flap folds back, card slides up */ + const sp = seg(t, 3.95, 4.2); + about(seal, BW / 2, BH * 0.55, 1 + 0.5 * oc(sp)); seal.setAttribute("opacity", 1 - sp); + const f = 1 - 2 * io(seg(t, 4.1, 4.55)); + about(flapF, 0, 0, 1, Math.max(0.001, f)); flapF.setAttribute("opacity", f > 0 ? 1 : 0); + about(flapB, 0, 0, 1, Math.min(-0.001, f)); flapB.setAttribute("opacity", f > 0 ? 0 : 1); + const ck = seg(t, 4.45, 5.15); + card.setAttribute("transform", `translate(0 ${16 + (-82 - 16) * bk(ck, 1.5)})`); + card.setAttribute("opacity", ck > 0 ? 1 : 0); + spk.forEach((n, i) => { + const k = seg(t, 5.0 + i * 0.05, 5.75 + i * 0.05); + draw(n, 16, oc(seg(k, 0, 0.45))); + n.setAttribute("opacity", k > 0 && k < 1 ? 1 - seg(k, 0.55, 1) : 0); + }); + }; + }, + }; + + /* ---- 1. an eligibility checker: plan picker, a US switch, a result panel ---- */ + SC.check = { + dur: 12, still: 12, + build(svg, api) { + const END = 12, READY = 2.6; + const hintT = hint(svg, "Try your plan", 756, 50); + const cardG = el("g", {}, svg); + el("rect", { x: 40, y: 78, width: 720, height: 486, rx: 26, fill: "#fff", stroke: INK, "stroke-width": 3 }, cardG); + const PLANS = ["Free", "Pro", "Max", "Team", "Enterprise"]; + const lab1 = el("g", {}, svg); + txt(lab1, 84, 138, "Plan", { "font-size": 23, "font-weight": 700 }); + const TX = 84, TWD = 632, TY = 158, TH = 62, SG = TWD / 5, CY1 = TY + TH / 2; + const track = el("rect", { x: TX, y: TY, width: TWD, height: TH, rx: TH / 2, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, svg); + const thumb = el("g", {}, svg), thumbIn = el("g", {}, thumb); + el("rect", { x: -SG / 2 + 6, y: -TH / 2 + 7, width: SG - 12, height: TH - 14, rx: (TH - 14) / 2, fill: YEL, stroke: INK, "stroke-width": 2.5 }, thumbIn); + const group = el("g", { role: "radiogroup", "aria-label": "Plan" }, svg); + const px = (i) => TX + SG * (i + 0.5); + let user = null, lastT = 0, disp = { plan: -1, us: false }; + const opts = PLANS.map((p, i) => { + const g = el("g", { role: "radio", tabindex: i ? -1 : 0, "aria-checked": "false", "aria-label": p, class: "mg-hit" }, group); + const inner = el("g", {}, g); + el("rect", { x: px(i) - SG / 2 + 2, y: TY + 2, width: SG - 4, height: TH - 4, rx: 26, fill: "transparent" }, inner); + const t = txt(inner, px(i), CY1 + 7.5, p, { "text-anchor": "middle", "font-size": 21, "font-weight": 700 }); + fit(t, SG - 26); + el("rect", { x: px(i) - SG / 2 + 1, y: TY - 6, width: SG - 2, height: TH + 12, rx: 30, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, inner); + g.addEventListener("click", () => setPlan(i)); + g.addEventListener("keydown", (e) => { + const m = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key]; + let to = -1; + if (m) to = (i + m + 5) % 5; else if (e.key === "Home") to = 0; else if (e.key === "End") to = 4; else if (e.key === "Enter" || e.key === " ") to = i; + if (to < 0) return; + e.preventDefault(); setPlan(to); if (to !== i) opts[to].g.focus(); + }); + return { g, inner }; + }); + const sep = [252, 346].map((y) => el("path", { d: `M84 ${y} H716`, stroke: SOFT, "stroke-width": 3, "stroke-linecap": "round", fill: "none" }, svg)); + const lab2 = el("g", {}, svg); + txt(lab2, 84, 307, "In the US", { "font-size": 23, "font-weight": 700 }); + const SX = 566, SWD = 96, SH = 46, SCY = 299; + const noT = txt(svg, SX - 14, SCY + 7, "No", { "text-anchor": "end", "font-size": 20, "font-weight": 600 }); + const yesT = txt(svg, SX + SWD + 14, SCY + 7, "Yes", { "font-size": 20, "font-weight": 600 }); + const sw = el("g", { role: "switch", tabindex: 0, "aria-checked": "false", "aria-label": "In the US", class: "mg-hit" }, svg); + const swIn = el("g", {}, sw); + const swTrack = el("rect", { x: SX, y: SCY - SH / 2, width: SWD, height: SH, rx: SH / 2, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, swIn); + const knob = el("g", {}, swIn), knobIn = el("g", {}, knob); + el("circle", { r: 16, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, knobIn); + el("rect", { x: SX - 8, y: SCY - SH / 2 - 8, width: SWD + 16, height: SH + 16, rx: SH / 2 + 8, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, swIn); + sw.addEventListener("click", () => setUS()); + sw.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUS(); } }); + + /* result panel */ + const PX = 84, PY = 374, PW = 632, PH = 150, PCY = PY + PH / 2; + const panelG = el("g", {}, svg); + const ring = el("rect", { x: PX, y: PY, width: PW, height: PH, rx: 24, fill: "none", stroke: MINT, "stroke-width": 5, opacity: 0 }, panelG); + const panel = el("rect", { x: PX, y: PY, width: PW, height: PH, rx: 24, fill: SOFT, stroke: INK, "stroke-width": 2.5 }, panelG); + const defs = el("defs", {}, svg), cp = el("clipPath", { id: "mg-wl-res" }, defs); + el("rect", { x: PX + 2, y: PY + 2, width: PW - 4, height: PH - 4, rx: 22 }, cp); + const wipeG = el("g", { "clip-path": "url(#mg-wl-res)" }, panelG); + const wipe = el("circle", { cx: PX + 72, cy: PCY, r: 0, fill: SOFT }, wipeG); + const resG = el("g", { "clip-path": "url(#mg-wl-res)" }, panelG); + const RES = [["Can join the waitlist", MINT, 1], ["Not included yet", LIL, 0], ["Needs Pro or Max", LIL, 0], ["Starting with the US", LIL, 0]]; + const rtx = RES.map(([s]) => el("g", { opacity: 0 }, resG)); + RES.forEach(([s], i) => fit(txt(rtx[i], PX + 132, PCY + 10, s, { "font-size": 29, "font-weight": 700 }), PW - 160)); + const icon = el("g", {}, panelG), iconIn = el("g", {}, icon); + el("circle", { r: 32, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, iconIn); + const tick = el("path", { d: "M-13 1 L-4 10 L13 -9", stroke: INK, "stroke-width": 5, "stroke-linecap": "round", "stroke-linejoin": "round", fill: "none" }, iconIn); + const clock = el("g", {}, iconIn); + el("path", { d: "M0 -16 V0 L11 7", stroke: INK, "stroke-width": 4.5, "stroke-linecap": "round", "stroke-linejoin": "round", fill: "none" }, clock); + el("circle", { r: 3, fill: INK }, clock); + const res = (s) => (s.plan < 0 ? -1 : s.plan === 0 ? 2 : s.plan >= 3 ? 1 : !s.us ? 3 : 0); + + /* autoplay: Free, then Pro, then the switch to Yes */ + const EV = [{ t: 0, plan: -1, us: false }, { t: 3.0, plan: 0, us: false }, { t: 5.3, plan: 1, us: false }, { t: 7.5, plan: 1, us: true }]; + const change = (plan, us) => { + api.poke(); + if (plan === disp.plan && us === disp.us) return; + user = { plan, us, prev: { plan: disp.plan, us: disp.us }, at: api.now() }; + if (lastT < READY) api.seek(READY); else api.poke(); + }; + function setPlan(i) { api.poke(); change(i, disp.us); } + function setUS() { api.poke(); change(disp.plan < 0 ? 1 : disp.plan, !disp.us); } + + return (t, now) => { + if (t < lastT - 0.5) user = null; + lastT = t; + const calm = api.calm; + /* entrance */ + const ck = seg(t, 0.1, 0.6); + about(cardG, 400, 321, 0.94 + 0.06 * bk(ck, 1.6)); cardG.setAttribute("opacity", seg(ck, 0, 0.4)); + rise(lab1, seg(t, 0.4, 0.85), 12); + const tk = seg(t, 0.5, 1.0); about(track, TX, CY1, Math.max(0.01, oc(tk)), 1); track.setAttribute("opacity", tk > 0 ? 1 : 0); + sep.forEach((n, i) => draw(n, 632, io(seg(t, 1.0 + i * 0.3, 1.6 + i * 0.3)))); + rise(lab2, seg(t, 1.1, 1.55), 12); + const sk = seg(t, 1.25, 1.65); about(swIn, SX + SWD / 2, SCY, 0.5 + 0.5 * bk(sk, 2)); swIn.setAttribute("opacity", seg(sk, 0, 0.3)); + [noT, yesT].forEach((n, i) => n.setAttribute("opacity", seg(t, 1.35 + i * 0.08, 1.6 + i * 0.08))); + const pk = seg(t, 1.55, 2.1); + panelG.setAttribute("transform", `translate(0 ${24 * (1 - bk(pk, 1.4))})`); panelG.setAttribute("opacity", seg(pk, 0, 0.35)); + hintT.setAttribute("opacity", seg(t, READY - 0.4, READY)); + + /* which state shows, the one before it, and since when */ + let cur, prev, since, ev = -1; + if (user) { cur = user; prev = user.prev; since = calm ? 9 : now - user.at; } + else { let i = 0; EV.forEach((e, j) => { if (t >= e.t) i = j; }); cur = EV[i]; prev = EV[Math.max(0, i - 1)]; since = t - EV[i].t; ev = i; } + if (cur.plan !== disp.plan || cur.us !== disp.us) { + opts.forEach((o, i) => { o.g.setAttribute("aria-checked", i === cur.plan ? "true" : "false"); o.g.setAttribute("tabindex", i === Math.max(0, cur.plan) ? 0 : -1); }); + sw.setAttribute("aria-checked", cur.us ? "true" : "false"); + } + disp = { plan: cur.plan, us: cur.us }; + + /* plan options pop in, then the pressed one dips */ + opts.forEach((o, i) => { + const k = seg(t, 0.7 + i * 0.08, 1.1 + i * 0.08); + let p = 0; + if (user) { if (i === cur.plan && cur.plan !== prev.plan) p = dip(seg(since, 0, 0.26)); } + else EV.forEach((e, j) => { if (j && e.plan === i && e.plan !== EV[j - 1].plan) p = Math.max(p, dip(seg(t, e.t - 0.2, e.t + 0.12))); }); + about(o.inner, px(i), CY1, (0.4 + 0.6 * bk(k, 2)) * (1 - 0.1 * p)); o.inner.setAttribute("opacity", seg(k, 0, 0.3)); + }); + /* the thumb slides with a stretch, or pops in on the first pick */ + if (cur.plan < 0) thumb.setAttribute("opacity", 0); + else if (prev.plan < 0 || prev.plan === cur.plan) { + const k = prev.plan < 0 ? seg(since, 0, 0.42) : 1; + tf(thumb, px(cur.plan), CY1); about(thumbIn, 0, 0, 0.3 + 0.7 * bk(k, 2.2)); thumb.setAttribute("opacity", seg(k, 0, 0.25)); + } else { + const k = seg(since, 0, 0.5), e = bk(k, 1.25); + tf(thumb, px(prev.plan) + (px(cur.plan) - px(prev.plan)) * e, CY1); + thumbIn.setAttribute("transform", `scale(${1 + 0.22 * dip(seg(k, 0, 0.7))} ${1 - 0.1 * dip(seg(k, 0, 0.7))})`); thumb.setAttribute("opacity", 1); + } + /* the switch knob */ + const KX0 = SX + 23, KX1 = SX + SWD - 23; + let on = cur.us ? 1 : 0, kx = cur.us ? KX1 : KX0, stretch = 0, sp = 0; + if (prev.us !== cur.us) { + const k = seg(since, 0, 0.42), e = bk(k, 1.6); + kx = prev.us ? KX1 + (KX0 - KX1) * e : KX0 + (KX1 - KX0) * e; + on = cur.us ? oc(k) : 1 - oc(k); stretch = dip(seg(k, 0, 0.6)); + } + if (user) { if (prev.us !== cur.us) sp = dip(seg(since, 0, 0.24)); } + else EV.forEach((e, j) => { if (j && e.us !== EV[j - 1].us) sp = Math.max(sp, dip(seg(t, e.t - 0.2, e.t + 0.12))); }); + tf(knob, kx, SCY); knobIn.setAttribute("transform", `scale(${1 + 0.3 * stretch} ${1 - 0.12 * stretch})`); + swTrack.setAttribute("fill", mix(SOFT, MINT, on)); + if (sk >= 1) about(swIn, SX + SWD / 2, SCY, 1 - 0.06 * sp); + noT.setAttribute("fill", on > 0.5 ? MUTE : INK); yesT.setAttribute("fill", on > 0.5 ? INK : MUTE); + noT.setAttribute("font-weight", on > 0.5 ? 500 : 700); yesT.setAttribute("font-weight", on > 0.5 ? 700 : 500); + + /* result: old line slides up and out, new one rises in, colour and icon follow */ + const rc = res(cur), rp = res(prev), same = rc === rp; + const colOf = (r) => (r < 0 ? SOFT : RES[r][1]); + /* the new colour spreads from the icon in a circle */ + const wk = same ? 1 : oc(seg(since, 0, 0.5)); + panel.setAttribute("fill", wk >= 1 ? colOf(rc) : colOf(rp)); + wipe.setAttribute("fill", colOf(rc)); wipe.setAttribute("r", wk >= 1 || wk <= 0 ? 0 : 640 * wk); + rtx.forEach((n, i) => { + if (i === rc) { const k = same ? 1 : seg(since, 0.2, 0.66); n.setAttribute("transform", `translate(0 ${70 * (1 - bk(k, 1.5))})`); n.setAttribute("opacity", seg(k, 0, 0.25)); } + else if (i === rp && !same) { const k = seg(since, 0, 0.22); n.setAttribute("transform", `translate(0 ${-70 * io(k)})`); n.setAttribute("opacity", 1 - seg(k, 0.3, 0.9)); } + else n.setAttribute("opacity", 0); + }); + const IX = PX + 72; + if (rc < 0) icon.setAttribute("opacity", 0); + else { + const ty = RES[rc][2], pty = rp < 0 ? -1 : RES[rp][2]; + let sx = 1, sy = 1, showT = ty; + if (rp < 0 && !same) { const k = seg(since, 0.05, 0.45); sx = sy = 0.3 + 0.7 * bk(k, 2.2); icon.setAttribute("opacity", seg(k, 0, 0.3)); } + else { + icon.setAttribute("opacity", 1); + if (!same && ty !== pty) { const k = seg(since, 0, 0.4); sx = Math.max(0.02, Math.abs(Math.cos(Math.PI * k))); showT = k < 0.5 ? pty : ty; } + else if (!same) { const k = seg(since, 0, 0.4); sx = sy = 1 - 0.12 * dip(k); } + } + const idle = rc === 0 ? 0.07 * bump(t, END, 1.8, 0, 0.45) * seg(t, 8.7, 9.1) * (user ? 0 : 1) : 0; + tf(icon, IX, PCY); iconIn.setAttribute("transform", `scale(${sx * (1 + idle)} ${sy * (1 + idle)})`); + tick.setAttribute("opacity", showT === 1 ? 1 : 0); clock.setAttribute("opacity", showT === 0 ? 1 : 0); + draw(tick, 36, showT === 1 ? (same || ty !== 1 ? 1 : oc(seg(since, 0.22, 0.6))) : 0); + if (showT === 1) tick.setAttribute("opacity", 1); + clock.setAttribute("transform", `rotate(${showT === 0 && !same && ty === 0 ? -120 * (1 - bk(seg(since, 0.2, 0.7), 1.8)) : 0})`); + } + /* a mint ring ripples out when the answer turns to yes */ + const rk = rc === 0 && !same ? seg(since, 0.3, 1.1) : 0; + about(ring, 400, PCY, 1 + 0.05 * oc(rk), 1 + 0.22 * oc(rk)); ring.setAttribute("opacity", rk > 0 && rk < 1 ? 0.9 * (1 - rk) : 0); + }; + }, + }; + + /* ---- 2. a white marble run with four lit stations ---- */ + SC.steps = { + dur: 13, still: 13, + build(svg, api) { + const END = 13; + const hintT = hint(svg, "Tap to drop the marble", 756, 44); + const Y = [140, 254, 368, 482], D = 22, XL = 104, XR = 696, R = 46, MR = 10.5; + const yAt = (k, x) => (k % 2 === 0 ? Y[k] + (D * (x - XL)) / (XR - XL) : Y[k] + (D * (XR - x)) / (XR - XL)); + const ST = [[0, 232, "Open the Claude Duet page"], [1, 470, "Join the waitlist"], [2, 272, "Wait for Anthropic's email"], [3, 154, "Open it on iOS, Android or claude.ai"]]; + const dipTo = (k, x, dir) => { const a = x - 26 * dir, b = x + 26 * dir; return `L${a} ${yAt(k, a)} Q${x} ${yAt(k, x) + 26} ${b} ${yAt(k, b)} `; }; + let d = `M60 50 L60 98 Q60 ${Y[0]} ${XL} ${Y[0]} `; + d += dipTo(0, ST[0][1], 1) + `L${XR} ${Y[0] + D} A${R} ${R} 0 0 1 ${XR} ${Y[1]} `; + d += dipTo(1, ST[1][1], -1) + `L${XL} ${Y[1] + D} A${R} ${R} 0 0 0 ${XL} ${Y[2]} `; + d += dipTo(2, ST[2][1], 1) + `L${XR} ${Y[2] + D} A${R} ${R} 0 0 1 ${XR} ${Y[3]} `; + const ex = ST[3][1], ey = yAt(3, ex); + d += `L${ex + 28} ${yAt(3, ex + 28)} Q${ex} ${ey + 28} ${ex - 26} ${ey + 4} Q${ex - 38} ${ey - 4} ${ex - 40} ${ey - 18}`; + const hopper = el("g", {}, svg); + el("path", { d: "M32 22 H88 L72 58 H48 Z", fill: SKY, stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, hopper); + const shadow = el("path", { d, fill: "none", stroke: SOFT, "stroke-width": 30, "stroke-linecap": "round", "stroke-linejoin": "round", transform: "translate(0 7)" }, svg); + const outer = el("path", { d, fill: "none", stroke: INK, "stroke-width": 30, "stroke-linecap": "round", "stroke-linejoin": "round" }, svg); + const innerP = el("path", { d, fill: "none", stroke: "#fff", "stroke-width": 24, "stroke-linecap": "round", "stroke-linejoin": "round" }, svg); + const L = outer.getTotalLength(), N = 1600, pts = []; + for (let i = 0; i <= N; i++) { const p = outer.getPointAtLength((L * i) / N); pts.push([p.x, p.y]); } + const at = (s) => { const u = (cl(s, 0, L) / L) * N, i = Math.min(N - 1, Math.floor(u)), f = u - i; return [pts[i][0] + (pts[i + 1][0] - pts[i][0]) * f, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * f]; }; + /* the lowest point of each dip is where a station sits */ + const sAt = (x, y) => { let b = 0, bd = 1e9; pts.forEach((p, i) => { const dd = (p[0] - x) ** 2 + (p[1] - y) ** 2; if (dd < bd) { bd = dd; b = i; } }); return (b * L) / N; }; + const SS = ST.map(([k, x]) => sAt(x, yAt(k, x) + 13)); + SS[3] = sAt(ex - 2, ey + 14); + /* stations: stem, lamp with its number, rays, label */ + const lamps = ST.map(([k, x, s], i) => { + const cy = yAt(k, x) - 54; + const stem = el("path", { d: `M${x} ${cy + 17} V${yAt(k, x) - 15}`, stroke: INK, "stroke-width": 2.5, "stroke-linecap": "round", fill: "none" }, svg); + const rays = el("g", {}, svg); + for (let r = 0; r < 8; r++) { const a = (r * Math.PI) / 4 + Math.PI / 8; el("path", { d: `M${x + Math.cos(a) * 23} ${cy + Math.sin(a) * 23} L${x + Math.cos(a) * 29} ${cy + Math.sin(a) * 29}`, stroke: INK, "stroke-width": 3, "stroke-linecap": "round" }, rays); } + const lamp = el("g", {}, svg); + const disc = el("circle", { cx: x, cy, r: 17, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, lamp); + txt(lamp, x, cy + 6.5, String(i + 1), { "text-anchor": "middle", "font-size": 18, "font-weight": 700 }); + const lab = el("g", {}, svg); + const lt = fit(txt(lab, x + 42, cy + 8, s, { "font-size": 22, "font-weight": 600, fill: MUTE }), 760 - x - 42); + return { stem, rays, lamp, disc, lab, lt, x, cy, len: yAt(k, x) - 15 - cy - 17 }; + }); + /* a tiny envelope by the email station */ + const envX = lamps[2].x + 42 + lamps[2].lt.getComputedTextLength() + 34, envY = lamps[2].cy; + const env = el("g", {}, svg), envIn = el("g", {}, env); + el("rect", { x: -18, y: -12, width: 36, height: 25, rx: 4, fill: SKY, stroke: INK, "stroke-width": 2.5 }, envIn); + el("path", { d: "M-17 -11 L0 3 L17 -11", fill: "none", stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, envIn); + /* marble with a highlight that turns as it rolls */ + const marble = el("g", {}, svg), mIn = el("g", {}, marble); + el("circle", { r: MR, fill: BLUE, stroke: INK, "stroke-width": 2.5 }, mIn); + const shine = el("circle", { r: 2.6, fill: "#fff" }, mIn); + /* button */ + const btn = el("g", {}, svg), btnIn = el("g", {}, btn); + const br = el("rect", { y: -24, height: 48, rx: 24, fill: YEL, stroke: INK, "stroke-width": 2.5 }, btnIn); + const bt = txt(btnIn, 0, 7, "Drop the marble", { "text-anchor": "middle", "font-size": 19, "font-weight": 700 }); + const bw = Math.ceil(bt.getComputedTextLength()) + 44; + br.setAttribute("x", -bw / 2); br.setAttribute("width", bw); + el("rect", { x: -bw / 2 - 7, y: -31, width: bw + 14, height: 62, rx: 31, fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, btnIn); + const BX = 752 - bw / 2, BY = 552; + let pressAt = -99; + button(btn, "Drop the marble", () => { pressAt = api.now(); api.seek(1.72); }); + + /* the run: drop and roll to the cup at station 3, rest, then roll on to the end */ + const prof = (u, a, dd) => { const v = 1 / (1 - a / 2 - dd / 2); if (u < a) return (v * u * u) / (2 * a); if (u < 1 - dd) return v * (a / 2 + (u - a)); const w = 1 - u; return 1 - (v * w * w) / (2 * dd); }; + const A0 = 2.0, A1 = 5.75, B0 = 7.45, B1 = 9.75; + const sOf = (t) => { + if (t < A0) return 0; + if (t < A1) return SS[2] * prof(seg(t, A0, A1), 0.16, 0.14); + if (t < B0) return SS[2] + 9 * wob(seg(t, A1, A1 + 1.0), 2.2); + if (t < B1) return SS[2] + (SS[3] - SS[2]) * prof(seg(t, B0, B1), 0.24, 0.1); + return SS[3] + 8 * wob(seg(t, B1, B1 + 0.9), 1.8); + }; + /* when the marble reaches each station */ + const TS = SS.map((s, i) => { if (i === 2) return A1; if (i === 3) return B1; for (let t = A0; t < A1; t += 0.002) if (sOf(t) >= s) return t; return A1; }); + + return (t, now) => { + const calm = api.calm; + const hk = seg(t, 0.1, 0.45); about(hopper, 60, 40, 0.4 + 0.6 * bk(hk, 2)); hopper.setAttribute("opacity", seg(hk, 0, 0.3)); + const dk = io(seg(t, 0.15, 1.5)); + [shadow, outer, innerP].forEach((n) => draw(n, L, dk)); + lamps.forEach((m, i) => { + const a = 0.45 + i * 0.26; + draw(m.stem, m.len, oc(seg(t, a, a + 0.2))); + const k = seg(t, a + 0.12, a + 0.5); + const lit = t >= TS[i] ? (calm ? 1 : seg(t, TS[i], TS[i] + 0.2)) : 0; + const pop = t >= TS[i] ? dip(seg(t, TS[i], TS[i] + 0.4)) : 0; + const idle = 0.07 * bump(t, END, 2.2, i * 0.16, 0.36) * seg(t, 10.4, 10.9); + about(m.lamp, m.x, m.cy, (0.3 + 0.7 * bk(k, 2.2)) * (1 + 0.28 * pop + idle)); + m.lamp.setAttribute("opacity", seg(k, 0, 0.25)); + m.disc.setAttribute("fill", mix("#ffffff", YEL, lit)); + const rk = t >= TS[i] ? seg(t, TS[i], TS[i] + 0.6) : 0; + about(m.rays, m.x, m.cy, 0.7 + 0.5 * oc(rk)); m.rays.setAttribute("opacity", rk > 0 && rk < 1 ? 1 - seg(rk, 0.4, 1) : 0); + rise(m.lab, seg(t, a + 0.2, a + 0.65), 12); + m.lt.setAttribute("fill", mix(MUTE, INK, lit)); + }); + /* envelope: pops in with its label, wobbles while the marble rests in the cup */ + const ek = seg(t, 1.17, 1.55), wk = seg(t, A1 + 0.15, B0 - 0.1); + tf(env, envX, envY - 7 * dip(seg(wk, 0, 0.18)) - 5 * dip(seg(wk, 0.42, 0.56)), 0.3 + 0.7 * bk(ek, 2.2), 14 * wob(wk, 4.5)); + env.setAttribute("opacity", seg(ek, 0, 0.3)); + /* marble */ + const mk = seg(t, 1.75, 1.98), s = sOf(t), [mx, my] = at(s); + tf(marble, mx, my, 0.2 + 0.8 * bk(mk, 2.4)); marble.setAttribute("opacity", mk > 0 ? 1 : 0); + const a = s / MR; + shine.setAttribute("cx", Math.cos(a - 2.2) * 4.2); shine.setAttribute("cy", Math.sin(a - 2.2) * 4.2); + /* button */ + const bp = calm ? 0 : dip(seg(now - pressAt, 0, 0.26)); + const bk2 = seg(t, 1.3, 1.7); + tf(btn, BX, BY, (0.5 + 0.5 * bk(bk2, 2)) * (1 - 0.08 * bp)); btn.setAttribute("opacity", seg(bk2, 0, 0.3)); + hintT.setAttribute("opacity", seg(t, 1.4, 1.75)); + }; + }, + }; + + /* ---- 3. three doors open in turn ---- */ + SC.meanwhile = { + dur: 12, still: 12, + build(svg, api) { + const END = 12, READY = 7.0; + const hintT = hint(svg, "Tap a door", 756, 46); + const capG = el("g", {}, svg); + txt(capG, 400, 92, "While you wait", { "text-anchor": "middle", "font-size": 26, "font-weight": 600, fill: MUTE }); + const DW = 216, GAP = 36, X0 = (800 - 3 * DW - 2 * GAP) / 2, TOP = 232, BOT = 500, RAD = 62, MID = (TOP + BOT) / 2, OPEN = 0.9; + const DOORS = [["Claude Cowork", "In paid Claude plans now", LIL], ["Munder Difflin", "Free and open source, on your computer", YEL], ["Claude Code", "For work inside your repo", SKY]]; + const arch = (l, top, w, bot, r) => `M${l} ${bot} V${top + r} A${r} ${r} 0 0 1 ${l + r} ${top} H${l + w - r} A${r} ${r} 0 0 1 ${l + w} ${top + r} V${bot} Z`; + const ground = [[SOFT, 14], [INK, 2.5]].map(([c, w]) => el("path", { d: `M26 ${BOT} H774`, stroke: c, "stroke-width": w, "stroke-linecap": "round", fill: "none" }, svg)); + /* wrap a line into centred rows that fit maxW */ + const wrap = (p, x, yMid, s, maxW, fs) => { + const probe = txt(p, 0, 0, "", { "font-size": fs, "font-weight": 600 }), rows = []; + let line = ""; + s.split(" ").forEach((w) => { const tr = line ? line + " " + w : w; probe.textContent = tr; if (probe.getComputedTextLength() > maxW && line) { rows.push(line); line = w; } else line = tr; }); + rows.push(line); probe.remove(); + const LH = fs * 1.36, y0 = yMid - ((rows.length - 1) * LH) / 2 + fs * 0.35; + return rows.map((r, i) => { const g = el("g", {}, p); txt(g, x, y0 + i * LH, r, { "text-anchor": "middle", "font-size": fs, "font-weight": 600 }); return g; }); + }; + let rep = [-99, -99, -99], lastT = 0; + const doors = DOORS.map(([name, line, col], k) => { + const l = X0 + k * (DW + GAP), r = l + DW, cx = l + DW / 2; + const spill = el("path", { d: `M${l + 8} ${BOT + 2} H${r - 8} L${r + 4} ${BOT + 42} H${l + 34} Z`, fill: mix(col, "#ffffff", 0.45), opacity: 0 }, svg); + const g = el("g", {}, svg), gIn = el("g", {}, g); + el("path", { d: arch(l - 10, TOP - 10, DW + 20, BOT, RAD + 10), fill: "#fff", stroke: INK, "stroke-width": 3, "stroke-linejoin": "round" }, gIn); + el("path", { d: arch(l, TOP, DW, BOT, RAD), fill: mix(col, "#ffffff", 0.62), stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }, gIn); + const textX = l + DW * (1 - OPEN) + 9 + (DW - DW * (1 - OPEN) - 9) / 2; + const rows = wrap(gIn, textX, MID + 14, line, DW - DW * (1 - OPEN) - 9 - 18, 21); + const leaf = el("g", {}, gIn); + el("path", { d: arch(l, TOP, DW, BOT, RAD), fill: col, stroke: INK, "stroke-width": 3, "stroke-linejoin": "round" }, leaf); + el("path", { d: arch(l + 26, TOP + 30, DW - 52, MID - 10, RAD - 26), fill: mix(col, "#ffffff", 0.3), stroke: INK, "stroke-width": 2, "stroke-linejoin": "round" }, leaf); + el("rect", { x: l + 26, y: MID + 14, width: DW - 52, height: BOT - MID - 40, rx: 12, fill: mix(col, "#ffffff", 0.3), stroke: INK, "stroke-width": 2 }, leaf); + const knob = el("g", {}, leaf); + el("circle", { cx: r - 24, cy: MID + 2, r: 8, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, knob); + const handle = el("path", { d: `M${r - 24} ${MID + 2} H${r - 44}`, stroke: INK, "stroke-width": 5, "stroke-linecap": "round" }, knob); + const edge = el("rect", { y: TOP + RAD, height: BOT - TOP - RAD, width: 0, fill: SOFT, stroke: INK, "stroke-width": 2, "stroke-linejoin": "round" }, gIn); + /* hanging sign above the door */ + const sign = el("g", {}, svg), signIn = el("g", {}, sign); + el("path", { d: `M0 0 L-56 28 M0 0 L56 28`, stroke: INK, "stroke-width": 2, fill: "none", "stroke-linecap": "round" }, signIn); + const sr = el("rect", { y: 25, height: 42, rx: 12, fill: "#fff", stroke: INK, "stroke-width": 2.5 }, signIn); + const st = fit(txt(signIn, 0, 53, name, { "text-anchor": "middle", "font-size": 21, "font-weight": 700 }), DW - 30); + const sw = Math.ceil(st.getComputedTextLength()) + 30; + sr.setAttribute("x", -sw / 2); sr.setAttribute("width", sw); + const nail = el("circle", { cx: cx, cy: 146, r: 4.5, fill: INK }, svg); + el("path", { d: arch(l - 18, TOP - 18, DW + 36, BOT + 8, RAD + 18), fill: "none", stroke: INK, "stroke-width": 2, "stroke-dasharray": "4 5", class: "mg-ring" }, g); + const a = 2.3 + k * 1.65; + const pick = () => { + const now = api.now(); + if (now - rep[k] < 0.35) return; + api.poke(); + rep[k] = now; + if (lastT < READY) api.seek(READY); else api.poke(); + }; + button(g, `${name}: ${line}`, pick); + g.addEventListener("focus", pick); + return { g, gIn, spill, rows, leaf, handle, edge, sign, signIn, nail, l, r, cx, a, k, col }; + }); + + return (t, now) => { + if (t < lastT - 0.5) rep = [-99, -99, -99]; + lastT = t; + const calm = api.calm; + rise(capG, seg(t, 0.15, 0.6), 12); + ground.forEach((n) => draw(n, 748, io(seg(t, 0.2, 0.9)))); + hintT.setAttribute("opacity", seg(t, READY - 0.4, READY)); + doors.forEach((D) => { + const k = D.k; + /* frame rises in, sign drops onto its nail and swings */ + const fk = seg(t, 0.4 + k * 0.11, 0.95 + k * 0.11); + D.gIn.setAttribute("transform", `translate(0 ${36 * (1 - bk(fk, 1.5))})`); D.gIn.setAttribute("opacity", seg(fk, 0, 0.3)); + const nk = seg(t, 0.85 + k * 0.1, 1.05 + k * 0.1); about(D.nail, D.cx, 146, bk(nk, 2)); D.nail.setAttribute("opacity", nk > 0 ? 1 : 0); + const sk = seg(t, 0.95 + k * 0.1, 1.35 + k * 0.1); + let swing = 9 * wob(seg(t, 1.35 + k * 0.1, 3.0 + k * 0.1), 2.2); + /* opening: the knob turns, the leaf swings with an overshoot */ + const ok = seg(t, D.a, D.a + 0.8); + let o = bk(ok, 1.5); + const turn = dip(seg(t, D.a - 0.42, D.a + 0.05)); + swing += 6 * wob(seg(t, D.a + 0.25, D.a + 1.8), 2); + o += 0.035 * bump(t, END, 2.6, k * 0.45, 0.5) * seg(t, 7.3, 7.9); + let since = 99; + if (rep[k] > -99) { + since = calm ? 9 : now - rep[k]; + if (since < 0.44) o = 1 - io(seg(since, 0, 0.26)); + else if (since < 1.2) o = bk(seg(since, 0.44, 1.2), 2.4); + swing += 13 * wob(seg(since, 0.24, 1.9), 2.4); + } + const thud = rep[k] > -99 ? dip(seg(since, 0.24, 0.42)) : 0; + const tw = rep[k] > -99 ? dip(seg(since, 0.34, 0.5)) : 0; + about(D.handle, D.r - 24, MID + 2, 1, 1, -38 * (turn + tw)); + const c = Math.max(0.035, 1 - OPEN * cl(o, -0.2, 1.15)); + about(D.leaf, D.l, MID, c, 1 + 0.035 * cl(o, 0, 1.15)); + const ew = 9 * cl(o, 0, 1.15); + D.edge.setAttribute("x", D.l + DW * c); D.edge.setAttribute("width", ew); D.edge.setAttribute("opacity", ew > 0.6 ? 1 : 0); + about(D.edge, D.l, MID, 1, 1 + 0.035 * cl(o, 0, 1.15)); + if (fk >= 1) about(D.gIn, D.cx, BOT, 1 + 0.015 * thud, 1 - 0.015 * thud); + D.spill.setAttribute("opacity", 0.95 * cl(o)); + /* the line behind the door rises in once the leaf is mostly open */ + D.rows.forEach((n, i) => { const v = rep[k] > -99 ? 1 : seg(t, D.a + 0.3 + i * 0.09, D.a + 0.75 + i * 0.09); rise(n, v, 12); }); + tf(D.sign, D.cx, 146, 1, swing); + D.signIn.setAttribute("transform", `translate(0 ${-20 * (1 - sk * sk)})`); + D.sign.setAttribute("opacity", seg(sk, 0, 0.3)); + }); + }; + }, + }; + + /* ---- player ---- */ + const q = new URLSearchParams(location.search), frozen = q.has("mgstill"), fixed = parseFloat(q.get("mgstill")); + const calm = matchMedia("(prefers-reduced-motion: reduce)").matches; + const mount = (fig) => { + const sc = SC[fig.dataset.scene], img = fig.querySelector("img"); if (!sc || !img) return; + const W = sc.w || 800, H = sc.h || 600; + const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "mg-svg", role: "group", "aria-label": img.alt || "" }); + svg.style.aspectRatio = `${W} / ${H}`; + el("rect", { width: W, height: H, fill: "#fff" }, svg); + img.replaceWith(svg); + let off = 0, raf = 0, seen = false, held = calm; + const base = performance.now(), clock = () => (performance.now() - base) / 1000; + let draw = () => {}; + const frame = () => { const now = clock(); draw(held ? sc.still : (((now + off) % sc.dur) + sc.dur) % sc.dur, now); }; + const tick = () => { cancelAnimationFrame(raf); frame(); if (seen && !held) raf = requestAnimationFrame(tick); }; + // with reduced motion a tap redraws the still frame with the new choice instead of starting playback + const api = { now: clock, calm, seek: (s) => { if (calm) return frame(); off = s - clock(); held = false; tick(); }, poke: () => { if (calm) return frame(); held = false; tick(); } }; + draw = sc.build(svg, api); + if (frozen) { draw(isNaN(fixed) ? sc.still : fixed, 0); return; } + new IntersectionObserver(([e]) => { + seen = e.isIntersecting; + if (seen) { tick(); if (!fig.dataset.sent && window.posthog) { fig.dataset.sent = 1; window.posthog.capture("blog_scene_view", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } } + else cancelAnimationFrame(raf); + }, { threshold: 0.25 }).observe(fig); + svg.addEventListener("click", () => { if (!fig.dataset.hit && window.posthog) { fig.dataset.hit = 1; window.posthog.capture("blog_scene_interact", { slug: location.pathname.split("/").filter(Boolean).pop(), scene: fig.dataset.scene }); } }); + frame(); + }; + const go = () => document.querySelectorAll("figure.mg[data-scene]").forEach(mount); + const fonts = document.fonts && document.fonts.load ? Promise.all([document.fonts.load('700 32px "Space Grotesk"'), document.fonts.load('500 20px "Space Grotesk"'), document.fonts.load('700 20px "JetBrains Mono"')]).catch(() => {}) : Promise.resolve(); + const start = () => fonts.then(go, go); + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); +})(); diff --git a/blog/src/assets/media/claude-duet-waitlist/steps.png b/blog/src/assets/media/claude-duet-waitlist/steps.png new file mode 100644 index 0000000000..e675134d13 Binary files /dev/null and b/blog/src/assets/media/claude-duet-waitlist/steps.png differ diff --git a/blog/src/posts/build-your-own-claude-duet.md b/blog/src/posts/build-your-own-claude-duet.md new file mode 100644 index 0000000000..ce8e46fcfe --- /dev/null +++ b/blog/src/posts/build-your-own-claude-duet.md @@ -0,0 +1,84 @@ +--- +title: "Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase" +seoTitle: "Build Your Own Claude Duet for Free: Morning Brief Agent for Developers" +description: "No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source." +date: 2026-10-22 +category: guides +categoryLabel: Guides +type: Technical +primaryKeyword: "build your own claude duet" +secondaryKeywords: ["claude duet for developers", "open source claude duet", "claude duet alternative free", "morning brief ai agent", "proactive coding agent", "claude code morning brief"] +tags: ["Guides", "AI Agents", "Automation", "Claude Code"] +faq: + - q: "Is this the same as Claude Duet?" + a: "No. Claude Duet is Anthropic's app that works on business apps in its own virtual machine and decides by itself when to message you. This build copies one idea, a brief that comes to you first, for a codebase, on your own computer, on a clock you set." + - q: "What do I need for this build?" + a: "Munder Difflin, which is free, a coding CLI such as Claude Code with a subscription or key, and the GitHub CLI signed in to your account so the agent can read pull requests, runs and issues. Slack is optional." + - q: "Does the brief run if my laptop is asleep?" + a: "No. Schedules run on your computer, so it has to be awake. Munder Difflin has a setting that keeps the computer awake while agents are live, and it works best on mains power." + - q: "Can I use Codex or Gemini CLI instead of Claude Code?" + a: "Yes. Each agent picks its own engine when you add it, so the brief agent can run Codex, Gemini CLI, OpenCode or another supported CLI. The prompt stays the same." +--- + +You can build your own Claude Duet for a codebase with one [Munder Difflin](https://harnessmd.com/download) agent, one daily schedule that asks it for a brief, and Slack for replies. It costs nothing beyond the coding subscription you already have. + +Claude Duet, Anthropic's proactive agent app, opened as a US waitlist on 21 Oct 2026 and is built for business apps ([what is Claude Duet?](/blog/what-is-claude-duet/)). Its best idea, a brief that comes to you before you ask, works just as well for code. Munder Difflin is free and open source: a desktop app that runs a team of coding agents such as Claude Code, Codex and Gemini CLI on your own computer. Labels below match the app's source on 22 Oct 2026. + +## What will this build do? + +Every morning at 08:00 an agent checks your repository and writes you a short brief: pull requests waiting for review, checks failing on main, and new issues. When you want something fixed, you say so in Slack and the work starts. Duet does the same for email and invoices: per [Anthropic](https://claude.com/resources/articles/claude-duet), "each morning, it sends you a short brief on the most important items across your connected apps." + +You need three things: + +- Munder Difflin installed, with the [install guide](/blog/how-to-install-and-use-munder-difflin/). +- A coding CLI, such as Claude Code, with a subscription or key. +- The [GitHub CLI](https://cli.github.com/) signed in, so the agent can run `gh pr list`, `gh run list` and `gh issue list`. + +## How do you set up the brief agent and its schedule? + +Add one agent, then give it a clock. Press **Add agent**, pick an engine such as Claude Code, and point it at your repository folder. Call it "brief agent" so the schedule is easy to read. + +Then add the schedule. In the Pro view open **Automations**, press **New automation** and choose **Schedule**. In the Classic view, open the Command Center's Triggers tab, which the app describes as "everything that can start work without you typing", and add a schedule there. Fill in: + +1. **Label**: Morning brief. +2. **When**: 08:00 every day, or weekdays only. In the Classic view pick every day, at 08:00; in the Pro view choose **Weekly** and set the days and time. +3. **Sends to**: brief agent. +4. **Message**: "List open pull requests, failing checks and new issues. Write me a short brief." + +Press **Save**. The message is sent word for word on every run. + +
Animation. A NEW SCHEDULE form fills in: LABEL Morning brief, GOES TO brief agent, WHEN Every day, 08:00, and a PROMPT asking for open pull requests, failing checks and new issues. Save is pressed and a saved tag appears. When the clock reaches 08:00 the prompt flies as an envelope to the brief agent's terminal. Tap Replay to watch again.
The schedule as it looks in the Classic view. The Pro view names the fields Label, When, Sends to and Message.
+ +## What does the morning brief look like? + +Three short sections, written by the agent from the GitHub CLI's output. The agent runs the commands in your repository, reads the results and keeps only what needs you. You read it in the agent's conversation in **Inbox**, which holds one conversation per agent. + +
Animation. A terminal runs gh pr list, gh run list and gh issue list. Each result flows into a Morning brief card: Pull requests: 3 waiting for review, Checks: 1 failing on main, Issues: 2 new since yesterday. The numbers are examples. Tap a section for one more detail.
A brief built from three GitHub CLI commands. The numbers are examples.
+ +A fuller version of the message stays short and specific: + +```text +List open pull requests waiting for my review, checks failing on main, +and issues opened since yesterday. Write me a brief of at most ten lines. +Put anything that blocks a release first. +``` + +Tune it with the message, not code. Ask for "only items older than one day", or "skip draft pull requests", and the next run follows. Keep it short: Duet checks "every few hours, because checking nonstop would use up your plan's limits much faster", and the same holds for your coding plan. One run a day is cheap; a run every ten minutes is not. + +Want more than one repository? Add one brief agent per repository, each pointed at its own folder, and give each its own schedule a few minutes apart. Each agent keeps its own memory file, so you can tell it once which items to skip. + +## How do you answer the brief from Slack? + +Connect Slack once, then reply in a thread. Create the Slack app from the manifest the app gives you: under **Integrations** in Pro, or **Settings**, then **Connections** in Classic, press **Copy manifest**. Our [Slack guide](/blog/connect-slack-to-munder-difflin/) has every step. Pick Michael, the orchestrator, under **Who answers inbound messages**. + +Now write "Fix the failing check on main" in a channel the bot is in. The project's [README](https://github.com/HarnessMD/munder-difflin) says Michael "can spawn an ephemeral worker, reply in-thread, and tear it down." + +
Animation. In a #standup thread you write Fix the failing check on main. The message travels to Michael, the orchestrator, which starts a short lived temp worker. The worker replies Fix ready for review in the same thread and folds away.
A reply in Slack becomes a short lived worker and an answer in the same thread.
+ +Spend, scope changes and destructive steps still come back to you for approval, and a circuit breaker stops an agent that loops. + +## What is still missing compared with Claude Duet? + +Judgment and the cloud. Duet decides what is urgent on its own and runs in a virtual machine with your laptop off. This build fires on your clock, on your awake computer, and sees only what its commands show. It works on your code, with any engine you pick, today, anywhere. For the full picture, read [Claude Duet vs Munder Difflin](/blog/claude-duet-vs-munder-difflin/) and [scheduling agent missions](/blog/scheduling-autonomous-agent-missions/). + + diff --git a/blog/src/posts/claude-duet-alternatives.md b/blog/src/posts/claude-duet-alternatives.md new file mode 100644 index 0000000000..06804f0ecd --- /dev/null +++ b/blog/src/posts/claude-duet-alternatives.md @@ -0,0 +1,76 @@ +--- +title: "Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist" +seoTitle: "4 Claude Duet Alternatives You Can Use Today, No Waitlist (Oct 2026)" +description: "Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control." +date: 2026-10-22 +category: comparisons +categoryLabel: Comparisons +type: Non-technical +primaryKeyword: "claude duet alternatives" +secondaryKeywords: ["claude duet alternative", "open source claude duet alternative", "free claude duet alternative", "apps like claude duet", "claude duet outside us"] +tags: ["Comparisons", "AI Agents", "Open Source", "Local-First"] +faq: + - q: "Is there a free alternative to Claude Duet?" + a: "Yes. Munder Difflin, OpenClaw and Hermes Agent are free and open source and run on hardware you control. You still need an AI model: a subscription you already pay for, an API key, or a local model. Claude Duet itself is included in Pro and Max during its research preview, by waitlist." + - q: "Can I use Claude Duet outside the US?" + a: "Not yet. Anthropic's announcement of 21 Oct 2026 says the preview starts with Pro and Max users in the US, and the product page says Team and Enterprise plans are not included yet. The open source picks on this page have no region limit." + - q: "Which Claude Duet alternative keeps working when my laptop is off?" + a: "Claude Cowork. Anthropic's help page says scheduled tasks run in the cloud with no device online. Munder Difflin, OpenClaw and Hermes Agent run on a machine you control, so that machine has to stay on, though Hermes Agent can live on a small server." + - q: "What is the best Claude Duet alternative for developers?" + a: "Munder Difflin, if your work is a codebase. It runs a team of coding agents such as Claude Code, Codex and Gemini CLI on your own computer, with schedules and Slack replies, while Duet is built for business apps like Gmail, QuickBooks and HubSpot." +--- + +The best Claude Duet alternatives today are Claude Cowork if you want Anthropic's cloud, and [Munder Difflin](https://harnessmd.com/download), OpenClaw or Hermes Agent if you want an agent on hardware you control. None of them has a waitlist. + +Munder Difflin is free and open source: a desktop app that runs a team of coding agents such as Claude Code, Codex and Gemini CLI on your own computer. Claude Duet is Anthropic's proactive agent app, opened on 21 Oct 2026 as a research preview. New to it? Start with [What is Claude Duet?](/blog/what-is-claude-duet/). Every fact below comes from vendor pages opened on 22 Oct 2026. + +## Why look for a Claude Duet alternative? + +Because most people cannot get it yet. From Anthropic's [announcement](https://claude.com/resources/articles/claude-duet) and [product page](https://claude.com/product/claude-duet): + +- **Waitlist.** Access is a research preview by waitlist. Anthropic says it will email you "as soon as Claude Duet's ready for you." +- **Region and plan.** It starts with Pro and Max subscribers in the US. Team and Enterprise plans "are not included yet." +- **Shared limits.** Its background checks count toward your plan's usage limits, the same pool as the rest of Claude. +- **Built for business apps.** Its examples are Gmail, QuickBooks, Gusto, Docusign, HubSpot and Shopify. Anthropic says it built "its core functionality around work", and that Duet "can't yet show you its browser." + +Duet has real strengths: a morning brief, checks every few hours, approval before it sends, pays, signs or deletes, and more than 6,000 connectors and plugins in the Claude Marketplace. If you can wait and live in the US, wait. Everyone else can start today with one of the four below. + +## Does it run in a vendor's cloud or on your hardware? + +This decides whether a closed laptop stops the work and who holds your files. Duet works in its own virtual machine, so your laptop can be off. One pick on this list works the same way; three run on hardware you control. + +
Animation. Five name tokens drop into two zones. Claude Duet and Claude Cowork settle in a cloud labelled A vendor's cloud. Munder Difflin, OpenClaw and Hermes Agent settle on a desk labelled Hardware you control. Flip the Laptop lid closed switch and the desk dims with the tag Paused until it wakes, while the cloud keeps pulsing with the tag Still working.
Where each agent runs, from vendor pages opened on 22 Oct 2026.
+ +**1. Claude Cowork.** [Cowork](/blog/what-is-claude-cowork/) is Anthropic's agent for longer work: you describe an outcome, step away and come back to finished documents, files or research. Its [help page](https://support.claude.com/en/articles/13345190-get-started-with-cowork) says it is on paid plans (Pro, Max, Team, Enterprise), runs sessions remotely in the cloud, and that "scheduled tasks run in the cloud, with no device online." The difference from Duet: you start the work. Best for: Duet's cloud model without the waitlist. + +**2. Munder Difflin.** [Munder Difflin](https://harnessmd.com/download) is what we make. Its [README](https://github.com/HarnessMD/munder-difflin) says it works "with the subscriptions you already pay for, on their hourly limits." Agents act without a prompt through schedules, a Slack channel can feed work to Michael, the lead agent, and spend, scope changes and destructive steps come back to you for approval. The catch: agents run while your computer runs. Best for: a proactive team for your codebase. [Claude Duet vs Munder Difflin](/blog/claude-duet-vs-munder-difflin/) compares the two in full. + +**3. OpenClaw.** [OpenClaw](https://github.com/openclaw/openclaw) is an open source (MIT) assistant that "runs on your own computer and meets you in the channels you already use": WhatsApp, Telegram, Slack, Discord, iMessage and more than 20 others. Best for: a personal assistant in your chat apps. + +**4. Hermes Agent.** [Hermes Agent](https://github.com/NousResearch/hermes-agent) from Nous Research is open source (MIT) with a learning loop: it creates skills from experience and builds a model of you across sessions. Its own cron scheduler runs "daily reports, nightly backups, weekly audits" unattended, and it answers on Telegram, Discord, Slack, WhatsApp, Signal or email. Best for: memory that grows, on a small server you control. + +## Which of them acts before you ask? + +Three of the four can. Duet's whole idea is that it comes to you first: a morning brief, then checks every few hours. Claude Cowork, Munder Difflin and Hermes Agent get there with schedules you set; OpenClaw answers when you message it. + +
Animation. A 24 hour dial with a sweeping hand. Coloured markers pop as the hand passes them, one colour per tool: Claude Duet's morning brief and checks every few hours, Claude Cowork's scheduled tasks, Munder Difflin's schedules and Hermes Agent's cron scheduler. Tap a tool to highlight its markers.
How each agent acts without a prompt. The marker times are examples.
+ +| Agent, 22 Oct 2026 | Runs on | Acts without a prompt | Who can get it | +| --- | --- | --- | --- | +| Claude Duet | Anthropic's virtual machine | Morning brief, checks every few hours | Pro and Max in the US, by waitlist | +| Claude Cowork | Anthropic's cloud | Scheduled tasks | Paid Claude plans | +| Munder Difflin | Your computer (macOS, Windows, Linux) | Schedules, Slack and webhooks | Anyone, free | +| OpenClaw | Your computer | Answers when you message it in your chat apps | Anyone, free | +| Hermes Agent | Your computer or server | Cron scheduler | Anyone, free | + +Our view: a schedule you wrote is less clever than Duet's judgment about what is urgent, but you know exactly when it fires and what it costs. + +## Which Claude Duet alternative should you pick? + +Start from the work, not the feature list. If the work must go on with your laptop shut, pick Claude Cowork. If your day is a codebase, pick Munder Difflin. If you want an assistant inside WhatsApp or Telegram, pick OpenClaw, and if you want memory that learns, pick Hermes Agent. + +
Animation. A marble drops through three question gates on a white marble run: Must it work with your laptop shut?, Is your work a codebase? and Want it in WhatsApp or Telegram? Each Yes or No exit leads to a bin: Claude Cowork, Munder Difflin, OpenClaw or Hermes Agent. Steer the marble with the Yes and No buttons.
Three questions that pick an alternative.
+ +Two more reads: [Build your own Claude Duet for your codebase](/blog/build-your-own-claude-duet/) walks through a free morning brief agent step by step, and [ChatGPT dots alternatives](/blog/chatgpt-dots-alternatives/) covers OpenAI's version of the same idea. To set up Munder Difflin, follow the [install guide](/blog/how-to-install-and-use-munder-difflin/). + + diff --git a/blog/src/posts/claude-duet-for-developers.md b/blog/src/posts/claude-duet-for-developers.md new file mode 100644 index 0000000000..431b7ef396 --- /dev/null +++ b/blog/src/posts/claude-duet-for-developers.md @@ -0,0 +1,67 @@ +--- +title: "Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't" +seoTitle: "Claude Duet for Developers: Can It Code? Duet vs Claude Code (Oct 2026)" +description: "Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better." +date: 2026-10-22 +category: comparisons +categoryLabel: Comparisons +type: Technical +primaryKeyword: "claude duet for developers" +secondaryKeywords: ["can claude duet code", "claude duet vs claude code", "claude duet coding", "claude duet github", "claude duet developers review"] +tags: ["Comparisons", "AI Agents", "Claude Code"] +faq: + - q: "Can Claude Duet write code?" + a: "Yes, in a limited sense. Anthropic's announcement lists prototyping and building web apps among the tasks you can hand it, and says the team behind Duet uses it to ship code. It is not presented as a tool for working inside your own repository; that is Claude Code's job." + - q: "Does Claude Duet replace Claude Code?" + a: "No. Anthropic positions them differently. Claude Code takes a bug fix, test or multi day migration and runs in your terminal, IDE, Slack or the web. Duet briefs you on your business apps and works in its own virtual machine with its own browser." + - q: "Does Claude Duet use the same limits as Claude Code?" + a: "It shares your plan's usage limits with the rest of Claude during the research preview, and its background checks count toward them. Anthropic does not say how that sits next to heavy Claude Code use, so check your usage page in the first week." + - q: "What should a developer use for a proactive agent on code?" + a: "A scheduled agent on your own repository. Munder Difflin runs coding agents such as Claude Code, Codex and Gemini CLI on your computer and can fire them on a schedule, for example a morning brief of pull requests, failing checks and new issues." +--- + +Claude Duet is not built for developers first. Anthropic made it for professionals and small business owners, and its examples are business apps. It can prototype and build web apps, but for work inside your repository, Claude Code is the better tool. + +This page compares Anthropic's [Claude Duet announcement](https://claude.com/resources/articles/claude-duet) of 21 Oct 2026 with its [Claude Code page](https://claude.com/product/claude-code), both opened on 22 Oct 2026. For a team of coding agents that also acts on a schedule, [Munder Difflin](https://harnessmd.com/download) is free and open source: a desktop app that runs a team of coding agents such as Claude Code, Codex and Gemini CLI on your own computer. + +## What can Claude Duet do with code? + +Some prototyping, on Anthropic's word. The announcement says you can hand it tasks "from simple administrative tasks of scheduling meetings to more complex ones, such as building dashboards with dozens of tools, creating financial or sales forecasts, prototyping, and building web apps." Anthropic also says the team behind Duet uses it to build the product, including prototypes, and to "even ship code." + +Everything else in its pitch is business work: missed Gmail and Slack messages, unpaid invoices in QuickBooks or Stripe, payroll in Gusto, agreements in Docusign, follow ups from HubSpot, and Shopify stores. Claude Code's page opens on a different job: "Hand Claude a bug fix, test, or multi-day migration." + +
Animation. Two overlapping circles. Claude Duet: business apps holds Gmail, QuickBooks, Gusto, Docusign, HubSpot and Shopify. Claude Code: your repo holds Bug fix, Tests, Migration and Code review. The overlap, Prototypes and web apps, holds Prototype and Web app. Tap a chip to see its source.
Where Duet and Claude Code overlap, from Anthropic's pages opened on 22 Oct 2026.
+ +Our view: the overlap is small. If you want a quick internal dashboard or a throwaway web app, Duet can do it from a message. If the change has to land in a codebase with tests and review, use a coding agent. + +## Where does Claude Duet run compared with Claude Code? + +Duet runs in its own cloud machine; Claude Code runs where your code is. Anthropic says Duet "has a virtual machine with its own browser, so it doesn't require your laptop or phone to be on." It also says Duet "can't yet show you its browser." Claude Code lets you "steer and review from your terminal, IDE, Slack, or web" and is available for macOS, Linux and Windows. + +
Animation. On the left, Claude Duet's virtual machine holds its own browser under a cloud. On the right, panels labelled Terminal, IDE, Slack and Web show where Claude Code runs, and a dotted line joins the Terminal panel to a folder tree labelled Your repo.
Duet works in its own virtual machine; Claude Code works next to your repository.
+ +That matters for a developer. Your repository, your test runner, your secrets in a local environment file and your Git history live on your machine or your CI. A coding agent that runs there can use them directly. Duet's connectors reach business apps; Anthropic's pages do not describe it opening a local repository. + +## What does Claude Duet ask before it acts? + +Anything that sends, pays, signs or deletes. Duet "reads, drafts, and prepares work on its own", then waits for your approval before those four kinds of step. Some actions, such as trading securities or moving money between accounts, are off limits even with approval. + +Developers will recognise the machinery. Anthropic says Duet's actions are approved by its "auto mode classifier", the same safeguard that "protects Claude and Claude Code from taking steps you didn't ask for", and that its handling of credentials follows the design of Claude Managed Agents, "so it never directly sees your passwords or sign-in tokens." Its defences against prompt injection come from Anthropic's browser use research. + +That is a sensible base for an agent that reads email. It is not the same as review on a pull request, which is where a coding agent's mistakes get caught. + +## Does Claude Duet share limits with Claude Code? + +Yes, during the preview. Duet "shares your plan's usage limits with the rest of Claude", and "its background checks count toward those limits." Anthropic adds that it checks your accounts every few hours "because checking nonstop would use up your plan's limits much faster." + +If Claude Code eats most of your Max plan every week, Duet's checks come out of the same pool. Anthropic does not say how the two weigh against each other, so watch the usage page in your first week. Our [Claude Code Max plan tips](/blog/claude-code-max-plan-tips/) cover where the allowance goes. + +## How should a developer use Claude Duet? + +As the morning assistant, not the engineer. Let Duet handle your inbox, calendar and invoices if you run a small business, keep Claude Code for the work at your keyboard, and put recurring code chores on a schedule that runs on your own machine. + +
Animation. A day timeline from 08:00 to 08:00. A yellow band marks Claude Duet's morning brief with checks every few hours, a blue band marks Claude Code from 10:00 to 18:00 with you at the keyboard, and a mint band marks Munder Difflin's scheduled agents from 22:00 to 06:00. Drag the time to lift each band.
One way to split a day between the three. The times are examples.
+ +For the overnight part, [build your own Claude Duet](/blog/build-your-own-claude-duet/) sets up a morning brief of pull requests, failing checks and new issues in Munder Difflin. [Claude Duet vs Munder Difflin](/blog/claude-duet-vs-munder-difflin/) compares the two directly, and [our office use cases](/blog/ai-agent-use-cases-our-office/) show what a team of coding agents does in a day. + + diff --git a/blog/src/posts/claude-duet-vs-munder-difflin.md b/blog/src/posts/claude-duet-vs-munder-difflin.md new file mode 100644 index 0000000000..a463eb1b20 --- /dev/null +++ b/blog/src/posts/claude-duet-vs-munder-difflin.md @@ -0,0 +1,71 @@ +--- +title: "Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer" +seoTitle: "Claude Duet vs Munder Difflin (Oct 2026): Cloud Agent or Free Agent Team?" +description: "Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side." +date: 2026-10-22 +category: comparisons +categoryLabel: Comparisons +type: Non-technical +primaryKeyword: "claude duet vs munder difflin" +secondaryKeywords: ["claude duet vs", "claude duet open source", "claude duet for coding", "munder difflin vs claude duet", "proactive coding agent"] +tags: ["Comparisons", "AI Agents", "Open Source", "Multi-Agent"] +faq: + - q: "Is Munder Difflin made by Anthropic?" + a: "No. Munder Difflin is an independent open source project, and we make it. It runs Anthropic's Claude Code as one of its engines, next to Codex, Gemini CLI, OpenCode and others. Claude Duet is Anthropic's own app." + - q: "Can Munder Difflin read my Gmail or QuickBooks like Claude Duet?" + a: "That is not what it is built for. Duet's examples are business apps through the Claude Marketplace. Munder Difflin runs terminal coding agents in your projects, so its natural work is pull requests, checks, issues and code. Treat them as tools for different jobs." + - q: "Does Munder Difflin work while my computer is off?" + a: "No. Its agents run on your computer, so the computer has to be awake. The app has a setting that keeps it awake while agents are live. Claude Duet runs in Anthropic's virtual machine, so your laptop and phone can be off." + - q: "Can I use both?" + a: "Yes. They do not overlap much. Duet can brief you on your inbox and invoices in the morning, while Munder Difflin runs coding agents on your repositories on a schedule. Both can use the same Claude subscription, so watch your usage page." +--- + +Claude Duet is one proactive agent that works in Anthropic's cloud on your business apps. [Munder Difflin](https://harnessmd.com/download) is free and open source: a desktop app that runs a team of coding agents such as Claude Code, Codex and Gemini CLI on your own computer. Pick Duet for your inbox and invoices, Munder Difflin for your repositories. + +Both were checked on 22 Oct 2026. Duet facts come from Anthropic's [announcement](https://claude.com/resources/articles/claude-duet) and [product page](https://claude.com/product/claude-duet). Munder Difflin facts come from its public [README](https://github.com/HarnessMD/munder-difflin). Munder Difflin is what we make, and we say where Duet is the better pick. + +## What is the main difference? + +One agent versus a team. Duet is a single agent: you talk to it "in one ongoing thread", by typing or voice, and it carries over your memory, skills and connected apps from your Claude account. Munder Difflin runs several named agents, each a real terminal session of a coding CLI such as Claude Code, Codex, Gemini CLI, OpenCode or Qwen Code. They pass messages through a shared hive, which the README describes as "a local git repo of plain files", and an orchestrator called Michael routes the work. + +
Animation. On the left, under Claude Duet, message bubbles stack in one ongoing thread. On the right, under Munder Difflin, terminal tiles for Claude Code, Codex, Gemini CLI and OpenCode pass envelopes through a central Shared hive box. Tap Add an agent to add a Qwen Code tile.
One thread versus a team that shares a hive. Based on Anthropic's Duet pages and the Munder Difflin README, 22 Oct 2026.
+ +Where they run differs too. Duet "has a virtual machine with its own browser", so it keeps working with your laptop and phone off. Munder Difflin runs on macOS, Windows or Linux, and its agents work while that computer is awake. + +## How do they keep you in control? + +Both stop before risky steps, and they draw the line in different places. Duet reads, drafts and prepares on its own, but before it sends a message, makes a payment, signs a document or deletes anything, "it shows you exactly what it's about to do and waits for your approval." Trading securities and moving money between accounts are off limits even with approval. + +In Munder Difflin, the orchestrator settles routine requests itself and sends spend, destructive operations and scope changes "into an approvals queue you act on." You set how far each agent may go on its own, and a circuit breaker "steers, constrains, then stops anything that loops or runs away." + +
Animation. Two belts. On the Claude Duet belt, Read, Draft and Prepare pass through while Send, Pay, Sign and Delete wait at a gate marked Your approval. On the Munder Difflin belt, routine requests pass an Orchestrator box while Spend, Scope change and Destructive step go to an Approvals queue. A looping agent token trips a Circuit breaker that lights Steer, then Constrain, then Stop.
What each tool does alone and what waits for you, from the vendors' own pages.
+ +Duet's safety work is deeper on sensitive data: Anthropic built a classifier and safety prompt designed to reject requests to share "the strictest tier of information, including credit card numbers and government identification." Munder Difflin's guard rails are about runaway agents and spend, which is where coding agents tend to go wrong. + +## Which usage limits does each one use? + +Both run on subscriptions you already pay for. During the preview, Duet is included in Pro and Max and "shares your plan's usage limits with the rest of Claude"; its background checks count toward those limits, and larger tasks need more model turns. Munder Difflin works "with the subscriptions you already pay for, on their hourly limits", across whichever CLIs you install, and adds per agent token budgets, bring your own keys, and local models through Ollama, LM Studio or vLLM. + +
Animation. On the left, one bar for Your Claude plan fills with Chats, Background checks and Large tasks. On the right, slim bars for up to four Munder Difflin agents fill toward dashed Budget lines, and one stops with the tag Stopped at budget. A slider sets how many agents run. The bars show the idea, not real numbers.
One shared plan versus per agent budgets. The bars show the idea, not real numbers.
+ +If you run both on one Claude plan, they draw from the same allowance. Anthropic does not say how Duet's checks weigh against heavy Claude Code use, so watch your usage page in the first week. + +## Should you choose Claude Duet or Munder Difflin? + +Choose by the work you want off your plate. Our read of both, on 22 Oct 2026: + +| | Claude Duet | Munder Difflin | +| --- | --- | --- | +| Built for | Professionals and small business owners | Developers and project work | +| Shape | One agent, one thread | A team of named agents | +| Runs on | Anthropic's virtual machine | Your computer | +| Works with | More than 6,000 connectors and plugins | Claude Code, Codex, Gemini CLI and more | +| Acts first by | Morning brief, checks every few hours | Schedules, Slack and webhooks | +| Access | Pro and Max in the US, by waitlist | Free download, macOS, Windows, Linux | +| Source | Closed | Open source (MIT) | + +Be honest about the catch on our side. Munder Difflin needs a computer that stays awake, the coding CLIs installed and signed in, and a little setup per agent. Duet needs none of that: Anthropic says setup "requires minimal work" because your memory, skills and connected apps carry over from your Claude account. + +Duet is the better pick if your day is email, invoices, payroll and contracts, and you are in the US on Pro or Max. Munder Difflin is the better pick if your day is pull requests and failing checks, if you are outside the US, or if you want several engines at once. [Build your own Claude Duet](/blog/build-your-own-claude-duet/) shows a morning brief agent for your codebase, step by step, and [Claude Duet alternatives](/blog/claude-duet-alternatives/) lists more options. New to Munder Difflin? Start with [your first hour](/blog/your-first-hour-with-munder-difflin/). + + diff --git a/blog/src/posts/claude-duet-waitlist.md b/blog/src/posts/claude-duet-waitlist.md new file mode 100644 index 0000000000..b711df3e26 --- /dev/null +++ b/blog/src/posts/claude-duet-waitlist.md @@ -0,0 +1,72 @@ +--- +title: "Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile" +seoTitle: "Claude Duet Waitlist (Oct 2026): How to Join, Who Qualifies, How Long" +description: "How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait." +date: 2026-10-22 +category: guides +categoryLabel: Guides +type: Non-technical +primaryKeyword: "claude duet waitlist" +secondaryKeywords: ["how to get claude duet", "claude duet access", "claude duet invite", "claude duet availability", "claude duet uk", "claude duet team plan"] +tags: ["Guides", "AI Agents"] +faq: + - q: "How long is the Claude Duet waitlist?" + a: "Anthropic has not said. Its announcement of 21 Oct 2026 says only that it is starting with Pro and Max users in the US and will email you as soon as Claude Duet is ready for you. Any wait time you read elsewhere is a guess." + - q: "Can I join the Claude Duet waitlist from outside the US?" + a: "Anthropic says the research preview starts with Pro and Max subscribers in the US. It does not give a date for other countries. If you are elsewhere, the open source alternatives on this page have no region limit." + - q: "Is Claude Duet on Team or Enterprise plans?" + a: "Not yet. The product page says Team and Enterprise plans are not included yet. During the preview, Duet is included in Pro and Max plans." + - q: "Does Claude Duet cost extra?" + a: "Not during the research preview. Anthropic says it is included in Pro and Max plans and uses the same usage limits as the rest of Claude, and its background checks count toward those limits." +--- + +To join the Claude Duet waitlist, open Anthropic's [Claude Duet page](https://claude.com/product/claude-duet) and press Join the waitlist. Access starts with Pro and Max subscribers in the US, and Anthropic emails you when Duet is ready for you. It has not given a date. + +Claude Duet is Anthropic's proactive agent app, opened as a research preview on 21 Oct 2026 ([what is Claude Duet?](/blog/what-is-claude-duet/)). This page uses Anthropic's [announcement](https://claude.com/resources/articles/claude-duet) and product page, both opened on 22 Oct 2026. + +## Who can join the Claude Duet waitlist? + +US subscribers on Pro or Max come first. The announcement says "we're starting today with Pro and Max users in the US." The product page adds: "Claude Duet is a research preview on a waitlist for Claude Pro and Max subscribers in the US. Team and Enterprise plans are not included yet." + +
Animation. An eligibility checker with a plan picker for Free, Pro, Max, Team and Enterprise and an In the US switch. Pro or Max in the US shows Can join the waitlist. Team or Enterprise shows Not included yet, Free shows Needs Pro or Max, and outside the US it shows Starting with the US.
Who is first in line, from Anthropic's Claude Duet pages on 22 Oct 2026.
+ +| Your situation, 22 Oct 2026 | What Anthropic says | +| --- | --- | +| Pro or Max, in the US | First in line for the research preview | +| Free plan | Duet is included in Pro and Max plans | +| Team or Enterprise | "not included yet" | +| Outside the US | Starting with the US; no date for elsewhere | + +## How do you join and get in? + +Four steps: two you do now, one wait, and one when the email arrives. Open the Claude Duet page, press Join the waitlist, then wait for Anthropic's email. Once you are in, Duet "is a standalone app for iOS and Android. On a computer, you can open it on the web at claude.ai." + +
Animation. A marble rolls down a white run through four stations that light in turn: Open the Claude Duet page, Join the waitlist, Wait for Anthropic's email, and Open it on iOS, Android or claude.ai. It pauses at the email station before rolling on.
From waitlist to first brief, as Anthropic describes it.
+ +Setup after that is light. Duet "carries over your memory, skills, and connected apps from your Claude account, so setting it up requires minimal work." Connect the apps you want it to watch before your invite lands; Anthropic says Duet can use "the over 6,000 connectors and plugins in the Claude Marketplace." + +## How long will the wait be? + +Nobody outside Anthropic knows. The announcement gives no timeline, only "we'll email as soon as Claude Duet's ready for you." It also says what Anthropic learns from early users "will shape how the two work together", meaning Duet and the main Claude app, so expect changes during the preview. + +Two things are worth knowing before your invite arrives. First, Duet's background checks count toward your plan's usage limits, so a heavy Claude Code week and Duet share one pool. Second, Anthropic calls this an early version that "may misread a thread or miscalibrate what's urgent, and it can miss something that arrives between checking in with you." + +## What will Claude Duet do once you are in? + +It comes to you first. Each morning it sends "a short brief on the most important items across your connected apps", and through the day it messages you only when something needs your attention. You talk to it in one ongoing thread, by typing or by voice, and hand it tasks from scheduling a meeting to building a dashboard or a sales forecast. + +It works in a virtual machine with its own browser, so your laptop and phone can be off, and you get a notification when a task is done or needs review. Before it sends a message, makes a payment, signs a document or deletes anything, it shows you exactly what it is about to do and waits for your approval. Trading securities and moving money between accounts are off limits even with approval. + +## What should you set up before your invite? + +Three small things. First, connect the apps you want Duet to watch in your Claude account, since it carries them over. Second, decide what you want to hear about: Anthropic says "you can also tell it what you want to hear about and how often." Third, look at your usage page now, so you can see what Duet's checks add once it starts. + +## What can you use while you wait? + +Three things work today. Claude Cowork is in paid Claude plans now, and its [help page](https://support.claude.com/en/articles/13345190-get-started-with-cowork) says scheduled tasks "run in the cloud, with no device online." [Munder Difflin](https://harnessmd.com/download) is free and open source: a desktop app that runs a team of coding agents such as Claude Code, Codex and Gemini CLI on your own computer, with schedules and Slack. Claude Code covers work inside your repository. + +
Animation. Under While you wait, three doors open in turn. Claude Cowork: In paid Claude plans now. Munder Difflin: Free and open source, on your computer. Claude Code: For work inside your repo.
Three things you can use today, checked on 22 Oct 2026.
+ +Our view: if your work is code, you do not need to wait at all. [Build your own Claude Duet](/blog/build-your-own-claude-duet/) sets up a daily brief of pull requests, failing checks and new issues, and [Claude Duet alternatives](/blog/claude-duet-alternatives/) lists four proactive agents with no waitlist. To try Munder Difflin, follow the [install guide](/blog/how-to-install-and-use-munder-difflin/). + + diff --git a/blog/src/posts/what-is-claude-duet.md b/blog/src/posts/what-is-claude-duet.md index 4c0b9e0aad..ed9a4f0013 100644 --- a/blog/src/posts/what-is-claude-duet.md +++ b/blog/src/posts/what-is-claude-duet.md @@ -71,7 +71,7 @@ The difference is who starts. Based on Anthropic's pages, opened on 22 Oct 2026: | Built for | Professionals and small business owners | "non-coding knowledge work" | "a bug fix, test, or multi-day migration" | | Where it runs | Its own virtual machine; app on iOS, Android and claude.ai | Desktop app, rolling out on web and mobile | Terminal, IDE, Slack or web; macOS, Linux and Windows | -Our [Claude Cowork explainer](/blog/what-is-claude-cowork/) and [Cowork vs Claude Code](/blog/claude-cowork-vs-claude-code/) cover the other two. Our view: Duet is built to decide by itself when to talk to you, which is why the approval rules above matter more than the feature list. For agents that write code, our [office use cases](/blog/ai-agent-use-cases-our-office/) show what a team of coding agents does in a day. +Our [Claude Cowork explainer](/blog/what-is-claude-cowork/) and [Cowork vs Claude Code](/blog/claude-cowork-vs-claude-code/) cover the other two. Can't get in yet? See the [Claude Duet waitlist](/blog/claude-duet-waitlist/), [Claude Duet alternatives](/blog/claude-duet-alternatives/), [Claude Duet for developers](/blog/claude-duet-for-developers/), [Claude Duet vs Munder Difflin](/blog/claude-duet-vs-munder-difflin/) and how to [build your own Claude Duet](/blog/build-your-own-claude-duet/). Our view: Duet is built to decide by itself when to talk to you, which is why the approval rules above matter more than the feature list. For agents that write code, our [office use cases](/blog/ai-agent-use-cases-our-office/) show what a team of coding agents does in a day. ## What are Claude Duet's known limits? diff --git a/docs/blog/agent-security-and-sandboxing/index.html b/docs/blog/agent-security-and-sandboxing/index.html index dc0f7f0ffa..d926d769ff 100644 --- a/docs/blog/agent-security-and-sandboxing/index.html +++ b/docs/blog/agent-security-and-sandboxing/index.html @@ -327,50 +327,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/agent-tools-today-2026-10-22/index.html b/docs/blog/agent-tools-today-2026-10-22/index.html index c569ca6c6c..9e0089dd04 100644 --- a/docs/blog/agent-tools-today-2026-10-22/index.html +++ b/docs/blog/agent-tools-today-2026-10-22/index.html @@ -233,7 +233,7 @@

How this list is built - - - - - - - -
-
-
+
diff --git a/docs/blog/indian-open-source-ai-projects-vs-big-tech/index.html b/docs/blog/indian-open-source-ai-projects-vs-big-tech/index.html index f68657cd8c..e29e6d6a53 100644 --- a/docs/blog/indian-open-source-ai-projects-vs-big-tech/index.html +++ b/docs/blog/indian-open-source-ai-projects-vs-big-tech/index.html @@ -628,50 +628,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/kilo-code-vs-cline/index.html b/docs/blog/kilo-code-vs-cline/index.html index dde692031f..a17873b7ac 100644 --- a/docs/blog/kilo-code-vs-cline/index.html +++ b/docs/blog/kilo-code-vs-cline/index.html @@ -334,50 +334,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/litellm-1-107-0-compromise/index.html b/docs/blog/litellm-1-107-0-compromise/index.html index 8e5354ed39..551ed5341b 100644 --- a/docs/blog/litellm-1-107-0-compromise/index.html +++ b/docs/blog/litellm-1-107-0-compromise/index.html @@ -386,50 +386,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 8 minPinned + 4 min
-

AI Agent Use Cases From a Real Company: Every Agent on Our Floor and the Job It Does

-

We run our company on Munder Difflin. Every seat on our floor: what each AI agent does, how its work starts, what comes out, and how to copy it.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/local-first-vs-cloud-agent-sdks/index.html b/docs/blog/local-first-vs-cloud-agent-sdks/index.html index 32a7cc4b5e..ba151a43ac 100644 --- a/docs/blog/local-first-vs-cloud-agent-sdks/index.html +++ b/docs/blog/local-first-vs-cloud-agent-sdks/index.html @@ -368,50 +368,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/manage-multiple-claude-code-sessions/index.html b/docs/blog/manage-multiple-claude-code-sessions/index.html index 76ae54d520..9433175840 100644 --- a/docs/blog/manage-multiple-claude-code-sessions/index.html +++ b/docs/blog/manage-multiple-claude-code-sessions/index.html @@ -324,50 +324,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/mcp-and-skills-in-a-hive/index.html b/docs/blog/mcp-and-skills-in-a-hive/index.html index 3863dd7886..c6f44fa15a 100644 --- a/docs/blog/mcp-and-skills-in-a-hive/index.html +++ b/docs/blog/mcp-and-skills-in-a-hive/index.html @@ -339,50 +339,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/mcp-security-tool-poisoning/index.html b/docs/blog/mcp-security-tool-poisoning/index.html index e3f0e21127..026ef1725f 100644 --- a/docs/blog/mcp-security-tool-poisoning/index.html +++ b/docs/blog/mcp-security-tool-poisoning/index.html @@ -328,50 +328,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/meta-muse-alternatives/index.html b/docs/blog/meta-muse-alternatives/index.html index 448c6814bc..211761f681 100644 --- a/docs/blog/meta-muse-alternatives/index.html +++ b/docs/blog/meta-muse-alternatives/index.html @@ -345,50 +345,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/meta-muse-grok-bot-privacy/index.html b/docs/blog/meta-muse-grok-bot-privacy/index.html index 578291b071..cf60664133 100644 --- a/docs/blog/meta-muse-grok-bot-privacy/index.html +++ b/docs/blog/meta-muse-grok-bot-privacy/index.html @@ -332,50 +332,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/meta-muse-vs-grok-bot/index.html b/docs/blog/meta-muse-vs-grok-bot/index.html index 4744caaedd..c052a19cd0 100644 --- a/docs/blog/meta-muse-vs-grok-bot/index.html +++ b/docs/blog/meta-muse-vs-grok-bot/index.html @@ -336,50 +336,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/munder-difflin-v0-2-4-feature-walkthrough/index.html b/docs/blog/munder-difflin-v0-2-4-feature-walkthrough/index.html index e141d739db..a013bf5fbd 100644 --- a/docs/blog/munder-difflin-v0-2-4-feature-walkthrough/index.html +++ b/docs/blog/munder-difflin-v0-2-4-feature-walkthrough/index.html @@ -304,50 +304,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/munder-difflin-vs-chatgpt-dots/index.html b/docs/blog/munder-difflin-vs-chatgpt-dots/index.html index 57266a3bd5..1593cfe2e7 100644 --- a/docs/blog/munder-difflin-vs-chatgpt-dots/index.html +++ b/docs/blog/munder-difflin-vs-chatgpt-dots/index.html @@ -358,50 +358,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/muse-code-vs-claude-code/index.html b/docs/blog/muse-code-vs-claude-code/index.html index 3e4b241f3c..6dfd5d7407 100644 --- a/docs/blog/muse-code-vs-claude-code/index.html +++ b/docs/blog/muse-code-vs-claude-code/index.html @@ -331,50 +331,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/omniagent-alternatives/index.html b/docs/blog/omniagent-alternatives/index.html index ab18df5400..f8791e3a0a 100644 --- a/docs/blog/omniagent-alternatives/index.html +++ b/docs/blog/omniagent-alternatives/index.html @@ -307,50 +307,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/open-source-ai-agents/index.html b/docs/blog/open-source-ai-agents/index.html index 423f8d081c..041edd6f22 100644 --- a/docs/blog/open-source-ai-agents/index.html +++ b/docs/blog/open-source-ai-agents/index.html @@ -374,50 +374,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/openclaw-alternatives/index.html b/docs/blog/openclaw-alternatives/index.html index 2919793dc8..7c09a2b7a1 100644 --- a/docs/blog/openclaw-alternatives/index.html +++ b/docs/blog/openclaw-alternatives/index.html @@ -359,50 +359,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/opencode-alternatives/index.html b/docs/blog/opencode-alternatives/index.html index 9761bcaf2a..2cefbd57d6 100644 --- a/docs/blog/opencode-alternatives/index.html +++ b/docs/blog/opencode-alternatives/index.html @@ -359,50 +359,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/opencode-vs-claude-code/index.html b/docs/blog/opencode-vs-claude-code/index.html index f18183d5ae..7c06db8284 100644 --- a/docs/blog/opencode-vs-claude-code/index.html +++ b/docs/blog/opencode-vs-claude-code/index.html @@ -341,50 +341,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/orca-vs-munder-difflin/index.html b/docs/blog/orca-vs-munder-difflin/index.html index e44fc72b1f..4134e05e7e 100644 --- a/docs/blog/orca-vs-munder-difflin/index.html +++ b/docs/blog/orca-vs-munder-difflin/index.html @@ -346,50 +346,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/prompt-caching-for-ai-agents/index.html b/docs/blog/prompt-caching-for-ai-agents/index.html index 85202a004f..235c0c0361 100644 --- a/docs/blog/prompt-caching-for-ai-agents/index.html +++ b/docs/blog/prompt-caching-for-ai-agents/index.html @@ -358,50 +358,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/qm-vs-munder-difflin/index.html b/docs/blog/qm-vs-munder-difflin/index.html index 7dadf4e51a..42785ffc2c 100644 --- a/docs/blog/qm-vs-munder-difflin/index.html +++ b/docs/blog/qm-vs-munder-difflin/index.html @@ -357,50 +357,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/run-a-mixed-engine-office/index.html b/docs/blog/run-a-mixed-engine-office/index.html index ed4f34b21c..97976a6ddc 100644 --- a/docs/blog/run-a-mixed-engine-office/index.html +++ b/docs/blog/run-a-mixed-engine-office/index.html @@ -286,50 +286,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/run-ai-agent-hive-from-slack-setup/index.html b/docs/blog/run-ai-agent-hive-from-slack-setup/index.html index ade8582cbf..8defc26d79 100644 --- a/docs/blog/run-ai-agent-hive-from-slack-setup/index.html +++ b/docs/blog/run-ai-agent-hive-from-slack-setup/index.html @@ -385,50 +385,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/run-munder-difflin-on-a-mac-mini/index.html b/docs/blog/run-munder-difflin-on-a-mac-mini/index.html index ee510a0710..3ebbf5fb0e 100644 --- a/docs/blog/run-munder-difflin-on-a-mac-mini/index.html +++ b/docs/blog/run-munder-difflin-on-a-mac-mini/index.html @@ -419,50 +419,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/run-munder-difflin-on-open-models/index.html b/docs/blog/run-munder-difflin-on-open-models/index.html index b6091c7009..4df9c4abff 100644 --- a/docs/blog/run-munder-difflin-on-open-models/index.html +++ b/docs/blog/run-munder-difflin-on-open-models/index.html @@ -535,50 +535,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/security-for-ai-coding-agents/index.html b/docs/blog/security-for-ai-coding-agents/index.html index ee1c067d1d..9e73ae5fb1 100644 --- a/docs/blog/security-for-ai-coding-agents/index.html +++ b/docs/blog/security-for-ai-coding-agents/index.html @@ -323,50 +323,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/small-team-ai-agents-playbook/index.html b/docs/blog/small-team-ai-agents-playbook/index.html index b5f89f6a4e..5b5e2af99d 100644 --- a/docs/blog/small-team-ai-agents-playbook/index.html +++ b/docs/blog/small-team-ai-agents-playbook/index.html @@ -328,50 +328,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/tags/ai-agents/index.html b/docs/blog/tags/ai-agents/index.html index 388459de5d..bd3d7208fb 100644 --- a/docs/blog/tags/ai-agents/index.html +++ b/docs/blog/tags/ai-agents/index.html @@ -105,11 +105,91 @@

Tag

#AI Agents

-

34 posts tagged AI Agents.

+

39 posts tagged AI Agents.

-
+ + + + + +
diff --git a/docs/blog/tags/automation/index.html b/docs/blog/tags/automation/index.html index 16716b912b..8d1ddda5bd 100644 --- a/docs/blog/tags/automation/index.html +++ b/docs/blog/tags/automation/index.html @@ -105,11 +105,27 @@

Tag

#Automation

-

16 posts tagged Automation.

+

17 posts tagged Automation.

-
+ +
diff --git a/docs/blog/tags/claude-code/index.html b/docs/blog/tags/claude-code/index.html index aabcd28ecb..dfde887b79 100644 --- a/docs/blog/tags/claude-code/index.html +++ b/docs/blog/tags/claude-code/index.html @@ -105,7 +105,7 @@

Tag

#Claude Code

-

97 posts tagged Claude Code.

+

99 posts tagged Claude Code.

@@ -125,6 +125,38 @@

Agent Tools Today, 22 Oct 2026

+ +
diff --git a/docs/blog/tags/comparisons/index.html b/docs/blog/tags/comparisons/index.html index e5bd42f5e6..5e35d39e02 100644 --- a/docs/blog/tags/comparisons/index.html +++ b/docs/blog/tags/comparisons/index.html @@ -105,11 +105,59 @@

Tag

#Comparisons

-

49 posts tagged Comparisons.

+

52 posts tagged Comparisons.

+ + +
diff --git a/docs/blog/tags/guides/index.html b/docs/blog/tags/guides/index.html index a8518e293a..b7c79af984 100644 --- a/docs/blog/tags/guides/index.html +++ b/docs/blog/tags/guides/index.html @@ -105,11 +105,27 @@

Tag

#Guides

-

43 posts tagged Guides.

+

45 posts tagged Guides.

+ +
diff --git a/docs/blog/tags/local-first/index.html b/docs/blog/tags/local-first/index.html index 7cef17a963..f98c20ef4b 100644 --- a/docs/blog/tags/local-first/index.html +++ b/docs/blog/tags/local-first/index.html @@ -105,11 +105,27 @@

Tag

#Local-First

-

23 posts tagged Local-First.

+

24 posts tagged Local-First.

+
diff --git a/docs/blog/tags/multi-agent/index.html b/docs/blog/tags/multi-agent/index.html index cfd56c03b8..6bb24a114f 100644 --- a/docs/blog/tags/multi-agent/index.html +++ b/docs/blog/tags/multi-agent/index.html @@ -105,11 +105,27 @@

Tag

#Multi-Agent

-

89 posts tagged Multi-Agent.

+

90 posts tagged Multi-Agent.

-
+ +
diff --git a/docs/blog/tags/open-source/index.html b/docs/blog/tags/open-source/index.html index a977ac2870..9f2a240cd5 100644 --- a/docs/blog/tags/open-source/index.html +++ b/docs/blog/tags/open-source/index.html @@ -105,7 +105,7 @@

Tag

#Open Source

-

80 posts tagged Open Source.

+

82 posts tagged Open Source.

@@ -125,6 +125,38 @@

Agent Tools Today, 22 Oct 2026

+ +
diff --git a/docs/blog/the-lethal-trifecta-for-coding-agents/index.html b/docs/blog/the-lethal-trifecta-for-coding-agents/index.html index d399a32b15..e2164e123c 100644 --- a/docs/blog/the-lethal-trifecta-for-coding-agents/index.html +++ b/docs/blog/the-lethal-trifecta-for-coding-agents/index.html @@ -346,50 +346,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/the-multi-agent-cost-playbook/index.html b/docs/blog/the-multi-agent-cost-playbook/index.html index 7b678e79cc..5b59dd5d10 100644 --- a/docs/blog/the-multi-agent-cost-playbook/index.html +++ b/docs/blog/the-multi-agent-cost-playbook/index.html @@ -328,50 +328,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/tmux-and-scripts-vs-an-agent-harness/index.html b/docs/blog/tmux-and-scripts-vs-an-agent-harness/index.html index 7e9c3d69b4..d96eb2a155 100644 --- a/docs/blog/tmux-and-scripts-vs-an-agent-harness/index.html +++ b/docs/blog/tmux-and-scripts-vs-an-agent-harness/index.html @@ -283,50 +283,50 @@

FAQ

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/topics/comparisons/index.html b/docs/blog/topics/comparisons/index.html index eb3b990811..2f01268433 100644 --- a/docs/blog/topics/comparisons/index.html +++ b/docs/blog/topics/comparisons/index.html @@ -116,9 +116,9 @@

comparisons

Honest side-by-sides with the other ways to concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 @@ -185,6 +185,54 @@

OmniAgent Alternatives: What to Use

+ + +
diff --git a/docs/blog/topics/concepts/index.html b/docs/blog/topics/concepts/index.html index 5693a87bb9..481b54235f 100644 --- a/docs/blog/topics/concepts/index.html +++ b/docs/blog/topics/concepts/index.html @@ -116,9 +116,9 @@

concepts

The ideas underneath the product — harnesses, concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 diff --git a/docs/blog/topics/guides/index.html b/docs/blog/topics/guides/index.html index e4f3bc104b..d748e15576 100644 --- a/docs/blog/topics/guides/index.html +++ b/docs/blog/topics/guides/index.html @@ -116,9 +116,9 @@

guides

Step-by-step walkthroughs you can follow today concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 @@ -217,6 +217,22 @@

Codex Max Plan? What ChatGPT Pro Gives

+
+
diff --git a/docs/blog/topics/index.html b/docs/blog/topics/index.html index 7ba1dd783b..02a54631c3 100644 --- a/docs/blog/topics/index.html +++ b/docs/blog/topics/index.html @@ -126,7 +126,7 @@

concepts

guides

-

54 posts.

+

56 posts.

View topic →
@@ -135,7 +135,7 @@

guides

comparisons

-

49 posts.

+

52 posts.

View topic →
@@ -200,25 +200,25 @@

memory

Tags - Claude Code97 + Claude Code99 - Multi-Agent89 + Multi-Agent90 - Open Source80 + Open Source82 Concepts53 - Comparisons49 + Comparisons52 - Guides43 + Guides45 - AI Agents34 + AI Agents39 Internals26 Cost25 - Local-First23 + Local-First24 CLI Agents22 @@ -234,7 +234,7 @@

memory

Daily Brief18 - Automation16 + Automation17 Release16 diff --git a/docs/blog/topics/internals/index.html b/docs/blog/topics/internals/index.html index 59add00ec4..1d1caeedae 100644 --- a/docs/blog/topics/internals/index.html +++ b/docs/blog/topics/internals/index.html @@ -116,9 +116,9 @@

internals

How the machine actually works: postmortems, a concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 diff --git a/docs/blog/topics/memory/index.html b/docs/blog/topics/memory/index.html index 7f22b1b2a0..a5cf76d6ae 100644 --- a/docs/blog/topics/memory/index.html +++ b/docs/blog/topics/memory/index.html @@ -116,9 +116,9 @@

memory

How agents remember across sessions — and how a concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 diff --git a/docs/blog/topics/news/index.html b/docs/blog/topics/news/index.html index ea64667b2a..47c9f05f9a 100644 --- a/docs/blog/topics/news/index.html +++ b/docs/blog/topics/news/index.html @@ -116,9 +116,9 @@

news

Agent Tools Today: a daily brief of the top five la concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 diff --git a/docs/blog/topics/orchestration/index.html b/docs/blog/topics/orchestration/index.html index f2734c56e3..822ae5a1cc 100644 --- a/docs/blog/topics/orchestration/index.html +++ b/docs/blog/topics/orchestration/index.html @@ -116,9 +116,9 @@

orchestration

Patterns for making one boss agent run a f concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 diff --git a/docs/blog/topics/story/index.html b/docs/blog/topics/story/index.html index 65106cfae8..e2b44f1fc3 100644 --- a/docs/blog/topics/story/index.html +++ b/docs/blog/topics/story/index.html @@ -116,9 +116,9 @@

story

Launches, releases, and the occasional confession concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 diff --git a/docs/blog/topics/use-cases/index.html b/docs/blog/topics/use-cases/index.html index eeac610243..5bfb532590 100644 --- a/docs/blog/topics/use-cases/index.html +++ b/docs/blog/topics/use-cases/index.html @@ -116,9 +116,9 @@

use-cases

Real workflows the hive actually runs — writ concepts56 - guides54 + guides56 - comparisons49 + comparisons52 internals19 diff --git a/docs/blog/trigger-ai-agents-from-github-webhook/index.html b/docs/blog/trigger-ai-agents-from-github-webhook/index.html index 2a0936ac42..423d514046 100644 --- a/docs/blog/trigger-ai-agents-from-github-webhook/index.html +++ b/docs/blog/trigger-ai-agents-from-github-webhook/index.html @@ -376,50 +376,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/trigger-ai-agents-from-slack/index.html b/docs/blog/trigger-ai-agents-from-slack/index.html index 0125ee0d9d..f99b194790 100644 --- a/docs/blog/trigger-ai-agents-from-slack/index.html +++ b/docs/blog/trigger-ai-agents-from-slack/index.html @@ -324,50 +324,50 @@

FAQ

Related in Guides

- +
- 5 min + 4 min
-

Claude API Version 2026-10-21: What Breaks and How to Upgrade

-

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

+

Build Your Own Claude Duet: A Free Morning Brief Agent for Your Codebase

+

No Claude Duet invite? Build a proactive morning brief for your code in Munder Difflin: one agent, one schedule, and Slack replies. Free and open source.

Guides - Read → + Read →
- +
- + 5 min
-

LiteLLM 1.107.0 Compromise: What It Steals and How to Check Your Machine

-

LiteLLM 1.107.0 on PyPI carried a credential stealer and a backdoor on 21 Oct 2026: who was exposed, how to check without Python, and what to rotate.

+

Claude API Version 2026-10-21: What Breaks and How to Upgrade

+

Claude API version 2026-10-21 removes temperature, top_p and top_k and ships new SDK majors. What returns a 400 now, what breaks proxies, and how to upgrade.

Guides - Read → + Read →
- +
- + - 5 min + 4 min
-

Claude Code Best Practices: 9 Habits From Anthropic's Docs

-

Nine Claude Code best practices from Anthropic's docs, checked 15 Oct 2026: give Claude a check, plan first, keep memory short, clear context, run in parallel.

+

Claude Duet Waitlist: Who Gets In First, and What to Use Meanwhile

+

How to join the Claude Duet waitlist, who qualifies first (US Pro and Max), what Anthropic says about timing, and three things you can use while you wait.

Guides - Read → + Read →
diff --git a/docs/blog/vibe-kanban-alternative/index.html b/docs/blog/vibe-kanban-alternative/index.html index 67b6973360..45d37d0a4f 100644 --- a/docs/blog/vibe-kanban-alternative/index.html +++ b/docs/blog/vibe-kanban-alternative/index.html @@ -273,50 +273,50 @@

Board or hive? How to choose

Related in Comparisons

- +
- + 4 min
-

Cline Alternatives: 6 Picks and 2 Names to Skip (Oct 2026)

-

Six Cline alternatives checked on 19 Oct 2026: Kilo Code, Munder Difflin, OpenCode, Codex, Claude Code and Cursor, plus two you can skip, by licence and price.

+

Claude Duet Alternatives: 4 AI Agents You Can Use Today, No Waitlist

+

Claude Duet is waitlisted and US only. Four proactive agents you can run today, sorted by where they run: a vendor's cloud or hardware you control.

Comparisons - Read → + Read →
- +
- + 4 min
-

Cursor vs Cline: A Paid Editor and an Open Source Agent Compared

-

Cursor vs Cline, checked 19 Oct 2026: Cursor's new X Subscription plans, Cline's free open source agent, which models each lets you use and which to pick.

+

Is Claude Duet Good for Developers? What It Does With Code and What It Doesn't

+

Claude Duet can prototype and build web apps, but it is built for business apps, not your repo. What developers get from it, and where Claude Code fits better.

Comparisons - Read → + Read →
- +
- + 4 min
-

Codex vs Gemini CLI: Which Terminal Agent Still Fits in Oct 2026?

-

Codex vs Gemini CLI, checked 19 Oct 2026: who can still sign in to Gemini CLI after the move to Antigravity CLI, Codex paid plans from $8, and which to pick.

+

Claude Duet vs Munder Difflin: One Agent in the Cloud or a Team on Your Computer

+

Claude Duet is one proactive agent in Anthropic's cloud. Munder Difflin runs a free team of coding agents on your computer. How they differ, side by side.

Comparisons - Read → + Read →
diff --git a/docs/blog/what-is-claude-duet/index.html b/docs/blog/what-is-claude-duet/index.html index 4a256a6a30..5405aec1c9 100644 --- a/docs/blog/what-is-claude-duet/index.html +++ b/docs/blog/what-is-claude-duet/index.html @@ -271,7 +271,7 @@

-

Our Claude Cowork explainer and Cowork vs Claude Code cover the other two. Our view: Duet is built to decide by itself when to talk to you, which is why the approval rules above matter more than the feature list. For agents that write code, our office use cases show what a team of coding agents does in a day.

+

Our Claude Cowork explainer and Cowork vs Claude Code cover the other two. Can’t get in yet? See the Claude Duet waitlist, Claude Duet alternatives, Claude Duet for developers, Claude Duet vs Munder Difflin and how to build your own Claude Duet. Our view: Duet is built to decide by itself when to talk to you, which is why the approval rules above matter more than the feature list. For agents that write code, our office use cases show what a team of coding agents does in a day.

What are Claude Duet’s known limits?

Anthropic lists them itself. This is an early version, and “it still might misread a thread or miscalibrate what’s urgent.” Its core is built around work, so personal errands such as booking tickets “aren’t quite as strong,” and it cannot yet show you its browser. The product page adds that it “can miss something that arrives between checking in with you.” It lives in its own app, separate from the main Claude app, and Anthropic says early users will shape how the two work together. Signing in to sites with logins saved in 1Password is announced but not yet available.

@@ -330,7 +330,7 @@

FAQ