From 610772eb925340e877bd6c0da44b8c1e7a4d0934 Mon Sep 17 00:00:00 2001 From: Nikos Benakis Date: Thu, 12 Nov 2026 22:05:31 +0200 Subject: [PATCH 01/15] Landing page: cursor-reactive hero, flow sketch and backoff chart - Hero: canvas dot field that lights up and moves aside near the cursor, with streaks crossing toward the code window; the code window tilts toward the cursor over a drifting glow. - Cursor spotlight on cards (lit edge and soft inner light). - New "How it flows" blueprint sketch: app -> one transaction -> SKIP LOCKED workers -> endpoints, with looping packets and a retry path. - Retry simulator: a log-scale backoff chart replaces the progress line. Loops run only while on screen; touch devices get a still grid; reduced motion gets static drawings. Co-Authored-By: Claude Opus 5.5 --- site/index.html | 385 ++++++++++++++++++++++++++++++++++++++++++----- site/motion.js | 392 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 739 insertions(+), 38 deletions(-) create mode 100644 site/motion.js diff --git a/site/index.html b/site/index.html index 56fee67..f593ad2 100644 --- a/site/index.html +++ b/site/index.html @@ -561,22 +561,59 @@ .sim { margin-top: 36px; } .sim-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } - .sim-track { position: relative; padding: 28px 24px 18px; } - .sim-line { position: absolute; left: 24px; right: 24px; top: 50px; height: 2px; background: var(--line); border-radius: 2px; overflow: hidden; } - .sim-progress { position: absolute; inset: 0; background: var(--accent); transform-origin: left; transform: scaleX(0); } - .sim-slots { position: relative; display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px; } - .sim-slot { display: grid; justify-items: center; gap: 10px; font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; } - .sim-slot .chip-wrap { height: 24px; display: flex; align-items: center; } - .sim-slot .attempt { animation: chip-in 260ms var(--ease-out) both; } + .sim-track { position: relative; padding: 20px 28px 14px 0; } + + /* Retry chart: attempts evenly spaced, elapsed time on a log scale, so the curve is the backoff. */ + .chart { position: relative; height: 248px; } + .chart-plot { position: absolute; left: 64px; right: 28px; top: 44px; bottom: 34px; } + .chart-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } + .chart-svg path, .chart-svg line { vector-effect: non-scaling-stroke; } + .chart-grid line { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 4; } + .chart-grid .chart-axis { stroke-dasharray: none; } + .chart-line-base { fill: none; stroke: color-mix(in oklch, var(--ink) 22%, transparent); stroke-width: 1.5; stroke-dasharray: 4 5; } + .chart-line { fill: none; stroke: var(--accent); stroke-width: 2.25; stroke-linejoin: round; } + .chart-area { fill: url(#chart-fill); } + .chart-stop-0 { stop-color: var(--accent); stop-opacity: 0.2; } + .chart-stop-1 { stop-color: var(--accent); stop-opacity: 0; } + .chart-y .tick, .chart-x .tick { + position: absolute; font: 500 11px/1 var(--mono); letter-spacing: 0; color: var(--ink-3); + font-variant-numeric: tabular-nums; white-space: nowrap; + } + .chart-y .tick { left: 24px; top: calc(44px + (1 - var(--y)) * (100% - 78px)); transform: translateY(-50%); } + .chart-x { position: absolute; left: 64px; right: 28px; bottom: 0; height: 22px; } + .chart-x .tick { left: calc(var(--x) * 100%); bottom: 4px; transform: translateX(-50%); } + .mark, .chart-head { position: absolute; left: calc(var(--x) * 100%); top: calc((1 - var(--y)) * 100%); } + .mark-dot { + position: absolute; left: -5px; top: -5px; width: 10px; height: 10px; border-radius: 50%; + background: var(--surface); box-shadow: inset 0 0 0 1.5px color-mix(in oklch, var(--ink) 28%, transparent); + transition: background-color 200ms var(--ease) 320ms, box-shadow 200ms var(--ease) 320ms; + } + .mark[data-state] .mark-dot { background: var(--tone); box-shadow: 0 0 0 4px color-mix(in oklch, var(--tone) 18%, transparent); } + .mark[data-state="ok"] { --tone: var(--ok); } + .mark[data-state="warn"] { --tone: var(--warn); } + .mark[data-state="bad"] { --tone: var(--bad); } + .mark-chip { position: absolute; left: 0; bottom: 12px; transform: translateX(-50%); } + .mark-chip .attempt { animation: chip-in 260ms var(--ease-out) 320ms both; box-shadow: 0 1px 2px oklch(25% 0.05 var(--h) / 0.08); } @keyframes chip-in { from { opacity: 0; transform: translateY(4px) scale(0.95); } } + .chart-head { + width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; pointer-events: none; + background: var(--accent); box-shadow: 0 0 0 5px color-mix(in oklch, var(--accent) 20%, transparent), 0 0 18px color-mix(in oklch, var(--accent) 55%, transparent); + opacity: 0; transition: opacity 200ms var(--ease); + } + .chart-head[data-on] { opacity: 1; } .sim-status { margin: 0; padding: 14px 24px 16px; border-top: 1px solid var(--line); font-size: 14.5px; color: var(--ink-2); min-height: 52px; } .sim-status b { font-weight: 600; } .sim-status .ok { color: var(--ok); } .sim-status .bad { color: var(--bad); } @media (max-width: 640px) { - .sim-slots { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: 18px; } - .sim-line { display: none; } - .sim-track { padding: 18px 14px 16px; } + /* Too narrow for eight chips side by side: the dots carry the colour. */ + .mark-chip { display: none; } + .chart { height: 200px; } + .chart-plot { left: 44px; right: 16px; top: 20px; } + .chart-x { left: 44px; right: 16px; } + .chart-y .tick { left: 10px; top: calc(20px + (1 - var(--y)) * (100% - 54px)); font-size: 10px; } + .chart-x .tick { font-size: 10px; } + .sim-track { padding: 14px 14px 10px 0; } .sim-status { padding: 14px 16px; } } @@ -670,6 +707,97 @@ .footer-links { display: flex; flex-wrap: wrap; gap: 8px 22px; } footer .footer-inner a { color: var(--ink-2); } + /* Hero field: a dot grid that leans away from the cursor, with webhooks crossing it. + Densest behind the code, gone under the headline so the text stays crisp. */ + /* Clip sideways only: the glow behind the code may spill past the gutter. */ + .hero { position: relative; isolation: isolate; overflow-x: clip; } + .hero-field { + position: absolute; inset: -56px 0 0; z-index: -1; width: 100%; height: calc(100% + 56px); pointer-events: none; + -webkit-mask-image: radial-gradient(ellipse 62% 70% at 72% 42%, #000 20%, transparent 78%); + mask-image: radial-gradient(ellipse 62% 70% at 72% 42%, #000 20%, transparent 78%); + } + @media (max-width: 960px) { + .hero-field { -webkit-mask-image: radial-gradient(ellipse 90% 50% at 50% 70%, #000 15%, transparent 75%); mask-image: radial-gradient(ellipse 90% 50% at 50% 70%, #000 15%, transparent 75%); } + } + + /* The code window floats: a light behind it drifts with the cursor and the card leans in. */ + .hero-stage { position: relative; perspective: 1400px; min-width: 0; } + .hero-glow { + position: absolute; inset: 8% -6% -10%; z-index: -1; border-radius: 40px; pointer-events: none; + background: + radial-gradient(50% 55% at 35% 40%, color-mix(in oklch, var(--accent) 34%, transparent), transparent 70%), + radial-gradient(45% 50% at 70% 70%, color-mix(in oklch, oklch(70% 0.14 200) 26%, transparent), transparent 70%); + transform: translate3d(var(--gx, 0px), var(--gy, 0px), 0); + will-change: transform; + } + :root[data-theme="dark"] .hero-glow { opacity: 0.7; } + .hero-code { + transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); + transform-style: preserve-3d; will-change: transform; + box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.08), 0 1px 2px oklch(25% 0.05 var(--h) / 0.08), 0 24px 48px -12px oklch(25% 0.08 var(--h) / 0.28), 0 60px 120px -40px oklch(35% 0.12 var(--h) / 0.35); + } + :root[data-theme="dark"] .hero-code { box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.08), 0 0 0 1px oklch(100% 0 0 / 0.1), 0 40px 100px -30px oklch(55% 0.16 var(--h) / 0.35); } + + /* Cursor spotlight: the card's edge catches the light where the pointer is. */ + [data-spot] { position: relative; } + [data-spot]::after { + content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 2; + padding: 1px; + background: radial-gradient(220px circle at var(--mx, -500px) var(--my, -500px), color-mix(in oklch, var(--accent) 75%, transparent), transparent 70%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); + opacity: 0; transition: opacity 300ms var(--ease); + } + [data-spot][data-lit]::after { opacity: 1; } + .code[data-spot] { background: radial-gradient(520px circle at var(--mx, -500px) var(--my, -500px), oklch(100% 0 0 / 0.05), transparent 45%), var(--code-bg); } + .window[data-spot], .seen-card[data-spot] { background: radial-gradient(420px circle at var(--mx, -500px) var(--my, -500px), color-mix(in oklch, var(--accent) 5%, transparent), transparent 60%), var(--surface); } + + /* Blueprint sketch */ + .sketch-band { padding-top: clamp(48px, 6vw, 80px); } + .flow-sketch { + margin: 36px 0 0; border-radius: 20px; overflow: hidden; + background: var(--surface); box-shadow: var(--raised); + } + .sketch-scroll { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; border-radius: inherit; } + .sketch-scroll:focus-visible { outline-offset: -2px; } + .sketch { display: block; width: 100%; min-width: 760px; height: auto; font-family: var(--sans); } + .sk-gridline { fill: none; stroke: color-mix(in oklch, var(--accent) 7%, transparent); stroke-width: 1; } + .sk-box { fill: var(--surface); stroke: color-mix(in oklch, var(--ink) 16%, transparent); stroke-width: 1; } + .sk-frame { fill: color-mix(in oklch, var(--accent) 3%, transparent); stroke: color-mix(in oklch, var(--accent) 45%, transparent); stroke-width: 1.25; stroke-dasharray: 5 5; } + .sk-glow { fill: color-mix(in oklch, var(--accent) 10%, transparent); stroke: var(--accent); stroke-width: 1.25; opacity: 0; } + .sk-glow-ok { fill: color-mix(in oklch, var(--ok) 10%, transparent); stroke: var(--ok); } + .sk-glow-bad { fill: color-mix(in oklch, var(--bad) 10%, transparent); stroke: var(--bad); } + .sk-title { font-size: 16px; font-weight: 600; letter-spacing: -0.015em; fill: var(--ink); } + .sk-label { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; fill: var(--ink); } + .sk-mono, .sk-note { font: 12px var(--mono); letter-spacing: 0; fill: var(--ink-3); } + .sk-dim { fill: color-mix(in oklch, var(--ink-3) 80%, transparent); } + .sk-ok { fill: var(--ok); } + .sk-warn { fill: var(--warn); } + .sk-bad, .sk-note-bad { fill: var(--bad); } + .sk-dot-ok { fill: var(--ok); } + .sk-dot-warn { fill: var(--warn); } + .sk-dot-bad { fill: var(--bad); } + .sk-line { fill: none; stroke: color-mix(in oklch, var(--ink) 26%, transparent); stroke-width: 1.25; } + .sk-retry { stroke: color-mix(in oklch, var(--bad) 45%, transparent); stroke-dasharray: 0.008 0.008; } + .sk-streak { fill: none; stroke-width: 2.5; stroke-linecap: round; } + .sk-head-halo { opacity: 0.22; } + /* First view: lines draw themselves and the boxes settle in, once. */ + .sk-line:not(.sk-retry) { stroke-dasharray: 1; } + .sk-line { transition: stroke-dashoffset 900ms var(--ease-out) 250ms, opacity 400ms var(--ease) 900ms; } + .sk-fade { transition: opacity 500ms var(--ease-out), transform 600ms var(--ease-out); transition-delay: calc(var(--d) * 60ms); } + .flow-sketch[data-pending] .sk-line:not(.sk-retry) { stroke-dashoffset: 1; } + .flow-sketch[data-pending] .sk-retry { opacity: 0; } + .flow-sketch[data-pending] .sk-fade { opacity: 0; transform: translateY(6px); } + @media (max-width: 640px) { + .flow-sketch { margin-top: 24px; border-radius: 16px; } + } + + @media (prefers-reduced-motion: reduce) { + .hero-code, .hero-glow { transform: none; } + .mark-dot, .chart-head, .sk-line, .sk-fade, [data-spot]::after { transition: none; } + .mark-chip .attempt { animation: none; } + } @media (prefers-reduced-motion: reduce) { .pill, .copy, .copy-btn, .copy-btn .glyph, .copy-btn .check, .search-trigger, .theme-toggle, .agent-link, .toast, .tab, .disclose .chev, .seg-thumb, .switch-ui::after, .ln, .verdict { transition: none; } .full-prompt, .full-prompt-body { transition: none; } @@ -711,6 +839,7 @@
+
@@ -718,7 +847,7 @@

Webhooks that commit with your data.

A TypeScript library that queues webhooks in your Postgres transaction, then signs, retries and logs every delivery. No extra service, no per‑message bill.

-
+
Agent

Add Hookgres webhooks to this app: hookgres.dev/llms.txt

@@ -750,7 +879,9 @@

Webhooks that commit with your data.

-
+
+ +
@@ -800,6 +931,7 @@

Webhooks that commit with your data.

} }
+
    @@ -813,6 +945,106 @@

    Webhooks that commit with your data.

+
+
+

How it flows

+

One commit in. Every endpoint, signed and retried.

+

send() writes a row next to your data. After COMMIT, workers claim it with SKIP LOCKED, sign the request and deliver it to each endpoint. A failure goes back on the schedule.

+
+ +
+ + Your app calls send() inside a transaction that updates the invoices table and inserts a hookgres.messages row. After COMMIT, three workers claim deliveries with FOR UPDATE SKIP LOCKED and post signed requests to acme.com (204), shop.example (503, then 204) and crm.example (500). The failed delivery loops back onto the retry schedule. + + + + + + + + + + + + + + + + + 500 · back on the schedule, 5s → 10h + + + + Your app + hookgres.send() + + + + + Postgres + one transaction + BEGIN + COMMIT + + + + + invoices + in_123 · status = paid + + + + + hookgres.messages + invoice.paid · 3 deliveries + + + FOR UPDATE SKIP LOCKED + + + + worker 1 + + + + + worker 2 + + + + + worker 3 + + + + + + acme.com + 204 · first attempt + + + + + + shop.example + 503 → 204 · attempt 3 + + + + + + crm.example + 500 · retrying + + + + + +
+
+
+
+
@@ -823,7 +1055,7 @@

Queued in your transaction. Sent only if it commits.

Sending webhooks

-
+
billing.tsChoose how the transaction ends
await client.query("BEGIN");await client.query(
   `UPDATE invoices SET status = 'paid'
@@ -865,15 +1097,15 @@ 

A job queue can't roll back with your data.

Not a contrived example. Other teams hit these bugs in 2026:

    -
  • +
  • “…the entire batch is rolled back, but the webhook for the earlier (now rolled-back) request has already been dispatched.”
    Apache Fineract, March 2026
  • -
  • +
  • “REST API dispatches events for writes that were rolled back”
    NetBox, August 2026
  • -
  • +
  • “If that process dies — for any reason — the webhook dies with it.”
    Dodo Payments, January 2026
  • @@ -906,8 +1138,23 @@

    Watch it retry.

    invoice.paid

    Press Send event.

    @@ -934,7 +1181,7 @@

    What your customers receive.

    -
    +
    POST https://acme.com/webhooks
    webhook-id: msg_…
     webhook-timestamp: …
    @@ -957,7 +1204,7 @@ 

    What your customers receive.

    Every attempt is a row you can query.

    Status, response body and timing for each request, stored next to your data. Build a customer-facing log, a support view or an alert with plain SQL.

    -
-
+
Failed in the last 24 hours
select e.tenant_id, e.url, d.last_error
 from hookgres.deliveries d
@@ -1079,7 +1326,7 @@ 

Ship webhooks this afternoon.

View on GitHub
-
+
Playground
$ git clone https://github.com/NickolasBenakis/hookgres
 $ cd hookgres
@@ -1381,28 +1628,85 @@ 

Ship webhooks this afternoon.

flaky: [503, 503, 204], down: [500, 500, 500, 500, 500, 500, 500, 500], }; - const slots = $(".sim-slots"); - const progress = $(".sim-progress"); + // Seconds since the first attempt, on a log scale: the curve is the backoff. + const elapsed = [0, 5, 305, 2105, 9305, 27305, 63305, 99305]; + const logT = (t) => Math.log10(1 + t) / Math.log10(1 + elapsed.at(-1)); + const points = elapsed.map((t, i) => [i / (elapsed.length - 1), logT(t)]); + const chart = $(".chart"); + const head = $(".chart-head", chart); + const reveal = $(".chart-reveal", chart); const clock = $(".sim-clock"); const status = $(".sim-status"); const sendButton = $("#sim-send"); let mode = "healthy"; let run = 0; - slots.replaceChildren( - ...labels.map((label) => { - const slot = document.createElement("div"); - slot.className = "sim-slot"; - const wrap = document.createElement("div"); - wrap.className = "chip-wrap"; - slot.append(wrap, label); - return slot; - }), - ); - const wraps = [...slots.querySelectorAll(".chip-wrap")]; + + // Monotone cubic through the attempts (Fritsch–Carlson), in a 1000×200 box. + const X = (x) => x * 1000; + const Y = (y) => 200 - y * 200; + const curve = (() => { + const n = points.length; + const d = points.slice(0, -1).map(([x, y], i) => (points[i + 1][1] - y) / (points[i + 1][0] - x)); + const m = points.map((_, i) => (i === 0 ? d[0] : i === n - 1 ? d[n - 2] : d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2)); + let path = `M${X(points[0][0])} ${Y(points[0][1])}`; + for (let i = 0; i < n - 1; i++) { + const [x0, y0] = points[i]; + const [x1, y1] = points[i + 1]; + const h = (x1 - x0) / 3; + path += `C${X(x0 + h)} ${Y(y0 + m[i] * h)} ${X(x1 - h)} ${Y(y1 - m[i + 1] * h)} ${X(x1)} ${Y(y1)}`; + } + return path; + })(); + $(".chart-line-base", chart).setAttribute("d", curve); + $(".chart-line", chart).setAttribute("d", curve); + $(".chart-area", chart).setAttribute("d", `${curve}L1000 200L0 200Z`); + const gridlines = [["5s", 5], ["1m", 60], ["1h", 3600], ["10h", 36000]]; + const lines = gridlines.map(([, t]) => ``).join(""); + $(".chart-grid", chart).innerHTML = `${lines}`; + $(".chart-y", chart).innerHTML = gridlines.map(([label, t]) => `${label}`).join(""); + $(".chart-x", chart).innerHTML = labels.map((label, i) => `${label}`).join(""); + const marks = $(".chart-marks", chart); + marks.innerHTML = points.map(([x, y]) => ``).join(""); + const markEls = [...marks.children]; + const wraps = markEls.map((mark) => $(".mark-chip", mark)); + + // The head rides the curve; sample it once to look up y for any x. + const samples = (() => { + const path = $(".chart-line-base", chart); + const length = path.getTotalLength(); + return Array.from({ length: 241 }, (_, i) => path.getPointAtLength((length * i) / 240)); + })(); + const yAt = (x) => { + const px = x * 1000; + const i = samples.findIndex((p) => p.x >= px); + if (i <= 0) return i === 0 ? samples[0].y : samples.at(-1).y; + const a = samples[i - 1]; + const b = samples[i]; + return a.y + ((b.y - a.y) * (px - a.x)) / (b.x - a.x || 1); + }; + let at = 0; + let tween = 0; + const place = (x) => { + at = x; + reveal.setAttribute("width", String(x * 1000)); + head.style.setProperty("--x", x); + head.style.setProperty("--y", 1 - yAt(x) / 200); + }; const setProgress = (fraction, animate) => { - progress.style.transition = animate && !reduceMotion ? "transform 420ms cubic-bezier(0.65, 0, 0.35, 1)" : "none"; - progress.style.transform = `scaleX(${fraction})`; + cancelAnimationFrame(tween); + head.toggleAttribute("data-on", fraction > 0 || animate); + if (!animate || reduceMotion) return place(fraction); + const from = at; + const start = performance.now(); + const step = (now) => { + const p = Math.min(1, (now - start) / 460); + const eased = p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2; + place(from + (fraction - from) * eased); + if (p < 1) tween = requestAnimationFrame(step); + }; + tween = requestAnimationFrame(step); }; + place(0); const say = (html) => { status.replaceChildren(); for (const part of html) status.append(part); @@ -1421,6 +1725,7 @@

Ship webhooks this afternoon.

const runMode = mode; const codes = outcomes[runMode]; for (const w of wraps) w.replaceChildren(); + for (const mark of markEls) delete mark.dataset.state; setProgress(0, false); clock.textContent = "t = 0s"; say(["Sending…"]); @@ -1429,16 +1734,19 @@

Ship webhooks this afternoon.

if (i > 0) await new Promise((resolve) => setTimeout(resolve, step)); if (id !== run) return; const code = codes[i]; + const tone = code < 300 ? "ok" : code >= 500 && runMode === "down" ? "bad" : "warn"; const chip = document.createElement("span"); - chip.className = `attempt ${code < 300 ? "ok" : code >= 500 && runMode === "down" ? "bad" : "warn"}`; + chip.className = `attempt ${tone}`; chip.textContent = code; wraps[i].append(chip); + markEls[i].dataset.state = tone; setProgress(i / (labels.length - 1), true); clock.textContent = `t = ${labels[i]}`; say([`Attempt ${i + 1}: HTTP ${code}`]); } await new Promise((resolve) => setTimeout(resolve, reduceMotion ? 0 : 300)); if (id !== run) return; + head.removeAttribute("data-on"); const n = codes.length; if (codes[n - 1] < 300) { say([strong("Succeeded", "ok"), ` after ${n} attempt${n > 1 ? "s" : ""}. Every attempt is stored in the delivery log.`]); @@ -1502,6 +1810,7 @@

Ship webhooks this afternoon.

update(); } + diff --git a/site/motion.js b/site/motion.js new file mode 100644 index 0000000..4d5bde9 --- /dev/null +++ b/site/motion.js @@ -0,0 +1,392 @@ +// Ambient motion for the landing page: the hero's dot field, the floating code +// window, the cursor spotlight on cards and the packets in the flow sketch. +// Everything runs only while it is on screen, and none of it runs for people +// who ask for reduced motion (they get the static drawing). +{ + const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; + const finePointer = matchMedia("(hover: hover) and (pointer: fine)").matches; + const root = document.documentElement; + + // One pointer position, shared. Smoothed copies chase it every frame. + const pointer = { x: -9999, y: -9999, active: false }; + addEventListener( + "pointermove", + (event) => { + if (event.pointerType !== "mouse") return; + pointer.x = event.clientX; + pointer.y = event.clientY; + pointer.active = true; + }, + { passive: true }, + ); + document.addEventListener("pointerleave", () => { + pointer.active = false; + }); + + // Runs `tick` every frame while `el` is on screen and the tab is visible. + const whileVisible = (el, tick, margin = "100px") => { + let onScreen = false; + let frame = 0; + const loop = (now) => { + tick(now); + frame = requestAnimationFrame(loop); + }; + const sync = () => { + cancelAnimationFrame(frame); + if (onScreen && !document.hidden) frame = requestAnimationFrame(loop); + }; + new IntersectionObserver( + (entries) => { + onScreen = entries.at(-1).isIntersecting; + sync(); + }, + { rootMargin: margin }, + ).observe(el); + document.addEventListener("visibilitychange", sync); + }; + + const dark = () => root.dataset.theme === "dark"; + // Canvas can't read the oklch tokens portably, so the field keeps its own sRGB pair. + const palette = () => + dark() + ? { dot: [236, 238, 245], accent: [118, 162, 245], base: 0.15 } + : { dot: [38, 44, 70], accent: [36, 92, 214], base: 0.2 }; + + // Hero field + const hero = document.querySelector(".hero"); + const canvas = document.querySelector(".hero-field"); + if (hero && canvas) { + const ctx = canvas.getContext("2d"); + const GAP = 22; + // Touch screens have no cursor to light the field, so they get the still + // grid instead of a full-time animation that costs battery. + const still = reduce || !finePointer; + let w = 0; + let h = 0; + let dots = []; + let colors = palette(); + const lens = { x: -9999, y: -9999, strength: 0 }; + const streaks = []; + let lastSpawn = 0; + + const resize = () => { + const dpr = Math.min(devicePixelRatio || 1, 2); + const rect = canvas.getBoundingClientRect(); + w = rect.width; + h = rect.height; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + dots = []; + const ox = ((w % GAP) + GAP) / 2; + for (let y = GAP / 2; y < h; y += GAP) { + for (let x = ox; x < w; x += GAP) + dots.push({ x, y, row: Math.round(y / GAP) }); + } + if (still) draw(); + }; + + const draw = () => { + ctx.clearRect(0, 0, w, h); + const [dr, dg, db] = colors.dot; + const [ar, ag, ab] = colors.accent; + const R = 170; + for (const d of dots) { + let x = d.x; + let y = d.y; + let glow = 0; + if (lens.strength > 0.01) { + const dx = d.x - lens.x; + const dy = d.y - lens.y; + const dist = Math.hypot(dx, dy); + if (dist < R) { + const f = (1 - dist / R) ** 2 * lens.strength; + const push = f * 7; + x += (dx / (dist || 1)) * push; + y += (dy / (dist || 1)) * push; + glow = f; + } + } + for (const s of streaks) { + if (s.row !== d.row) continue; + const behind = s.x - d.x; + if (behind > 0 && behind < s.len) + glow = Math.max(glow, (1 - behind / s.len) * s.alpha); + } + const size = 1.3 + glow * 1.4; + ctx.fillStyle = + glow > 0.02 + ? `rgba(${ar},${ag},${ab},${colors.base + glow * 0.75})` + : `rgba(${dr},${dg},${db},${colors.base})`; + ctx.fillRect(x - size / 2, y - size / 2, size, size); + } + // A webhook in flight: a short comet along one row of the grid. + for (const s of streaks) { + const y = s.row * GAP - GAP / 2; + const grad = ctx.createLinearGradient(s.x - s.len, y, s.x, y); + grad.addColorStop(0, `rgba(${ar},${ag},${ab},0)`); + grad.addColorStop(1, `rgba(${ar},${ag},${ab},${0.55 * s.alpha})`); + ctx.fillStyle = grad; + ctx.fillRect(s.x - s.len, y - 0.75, s.len, 1.5); + ctx.fillStyle = `rgba(${ar},${ag},${ab},${0.9 * s.alpha})`; + ctx.beginPath(); + ctx.arc(s.x, y, 1.9, 0, Math.PI * 2); + ctx.fill(); + } + }; + + let last = 0; + const tick = (now) => { + const dt = Math.min(48, now - (last || now)) / 1000; + last = now; + const rect = canvas.getBoundingClientRect(); + const inside = + pointer.active && pointer.y > rect.top && pointer.y < rect.bottom; + const tx = pointer.x - rect.left; + const ty = pointer.y - rect.top; + if (lens.strength < 0.01 && inside) { + lens.x = tx; + lens.y = ty; + } + lens.x += (tx - lens.x) * 0.14; + lens.y += (ty - lens.y) * 0.14; + lens.strength += ((inside ? 1 : 0) - lens.strength) * 0.08; + + if (now - lastSpawn > 700 + Math.random() * 900 && streaks.length < 4) { + lastSpawn = now; + const rows = Math.floor(h / GAP); + streaks.push({ + row: 1 + Math.floor(Math.random() * Math.max(1, rows - 1)), + x: w * (0.25 + Math.random() * 0.2), + len: 90 + Math.random() * 70, + speed: 260 + Math.random() * 180, + alpha: 0, + }); + } + for (let i = streaks.length - 1; i >= 0; i--) { + const s = streaks[i]; + s.x += s.speed * dt; + const life = s.x / w; + s.alpha = Math.min(1, (life - 0.25) * 6) * Math.min(1, (1 - life) * 4); + if (s.x - s.len > w) streaks.splice(i, 1); + } + draw(); + }; + + new ResizeObserver(resize).observe(canvas); + new MutationObserver(() => { + colors = palette(); + if (still) draw(); + }).observe(root, { attributes: true, attributeFilter: ["data-theme"] }); + if (!still) whileVisible(hero, tick, "0px"); + } + + // Floating code window: leans toward the cursor, its light drifts the other way. + const stage = document.querySelector(".hero-stage"); + if (stage && finePointer && !reduce) { + const card = stage.querySelector(".hero-code"); + const glow = stage.querySelector(".hero-glow"); + const cur = { rx: 0, ry: 0, gx: 0, gy: 0 }; + whileVisible(stage, () => { + const rect = stage.getBoundingClientRect(); + // -1..1 across a zone a bit larger than the card, 0 when the pointer is away. + const nx = pointer.active + ? Math.max( + -1, + Math.min( + 1, + (pointer.x - (rect.left + rect.width / 2)) / (rect.width * 0.9), + ), + ) + : 0; + const ny = pointer.active + ? Math.max( + -1, + Math.min( + 1, + (pointer.y - (rect.top + rect.height / 2)) / (rect.height * 0.9), + ), + ) + : 0; + const target = { + rx: -ny * 2.4, + ry: nx * 3.2, + gx: -nx * 22, + gy: -ny * 18, + }; + let moving = false; + for (const k in cur) { + cur[k] += (target[k] - cur[k]) * 0.08; + if (Math.abs(target[k] - cur[k]) > 0.01) moving = true; + } + if (!moving) return; + card.style.setProperty("--rx", `${cur.rx.toFixed(3)}deg`); + card.style.setProperty("--ry", `${cur.ry.toFixed(3)}deg`); + glow.style.setProperty("--gx", `${cur.gx.toFixed(2)}px`); + glow.style.setProperty("--gy", `${cur.gy.toFixed(2)}px`); + }); + } + + // Spotlight: each visible card learns where the pointer is, relative to itself. + if (finePointer && !reduce) { + const visible = new Set(); + const io = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target); + else { + visible.delete(entry.target); + delete entry.target.dataset.lit; + } + } + }); + for (const el of document.querySelectorAll("[data-spot]")) io.observe(el); + let queued = false; + const update = () => { + queued = false; + for (const el of visible) { + const r = el.getBoundingClientRect(); + const x = pointer.x - r.left; + const y = pointer.y - r.top; + const near = + pointer.active && + x > -120 && + y > -120 && + x < r.width + 120 && + y < r.height + 120; + if (near) { + el.style.setProperty("--mx", `${x}px`); + el.style.setProperty("--my", `${y}px`); + } + el.toggleAttribute("data-lit", near); + } + }; + const queue = () => { + if (queued) return; + queued = true; + requestAnimationFrame(update); + }; + addEventListener("pointermove", queue, { passive: true }); + addEventListener("scroll", queue, { passive: true }); + document.addEventListener("pointerleave", queue); + } + + // Flow sketch: draws itself once, then packets run the path of one commit. + const sketch = document.querySelector(".flow-sketch"); + if (sketch) { + const svg = sketch.querySelector("svg"); + const layer = svg.querySelector(".sk-packets"); + const NS = "http://www.w3.org/2000/svg"; + const flashes = Object.fromEntries( + [...svg.querySelectorAll("[data-flash]")].map((el) => [ + el.dataset.flash, + el, + ]), + ); + const path = (id) => svg.querySelector(`#sk-${id}`); + const tone = { + accent: "var(--accent)", + ok: "var(--ok)", + warn: "var(--warn)", + bad: "var(--bad)", + }; + // [path, start s, duration s, colour, node to light on arrival] + const CYCLE = 5.2; + const plan = [ + ["a1", 0, 0.6, "accent", "row1"], + ["a2", 0, 0.6, "accent", "row2"], + ["m1", 1.0, 0.6, "accent", "w1"], + ["m2", 1.08, 0.6, "accent", "w2"], + ["m3", 1.16, 0.6, "accent", "w3"], + ["e1", 1.75, 0.45, "ok", "e1"], + ["e2", 1.83, 0.45, "warn", "e2"], + ["e3", 1.91, 0.45, "bad", "e3"], + ["r3", 2.6, 1.6, "bad", "row2"], + ].map(([id, start, dur, color, flash]) => { + const el = path(id); + const len = el.getTotalLength(); + const streak = document.createElementNS(NS, "path"); + streak.setAttribute("d", el.getAttribute("d")); + streak.setAttribute("class", "sk-streak"); + streak.style.stroke = tone[color]; + const halo = document.createElementNS(NS, "circle"); + halo.setAttribute("r", "8"); + halo.setAttribute("class", "sk-head-halo"); + halo.style.fill = tone[color]; + const headDot = document.createElementNS(NS, "circle"); + headDot.setAttribute("r", "3.5"); + headDot.style.fill = tone[color]; + const group = document.createElementNS(NS, "g"); + group.style.opacity = "0"; + group.append(streak, halo, headDot); + layer.append(group); + return { + el, + len, + start, + dur, + flash, + streak, + halo, + headDot, + group, + tail: Math.min(46, len * 0.45), + }; + }); + // Arrivals per node, so a node lights after each packet reaches it. + const arrivals = {}; + for (const p of plan) { + arrivals[p.flash] ??= []; + arrivals[p.flash].push(p.start + p.dur); + } + + const ease = (p) => (p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2); + let t0 = 0; + const frame = (now) => { + if (!t0) t0 = now; + const t = ((now - t0) / 1000) % CYCLE; + for (const p of plan) { + const raw = (t - p.start) / p.dur; + if (raw < 0 || raw > 1) { + p.group.style.opacity = "0"; + continue; + } + const at = ease(raw) * p.len; + const pt = p.el.getPointAtLength(at); + p.group.style.opacity = String(Math.min(1, raw * 6, (1 - raw) * 6)); + p.streak.style.strokeDasharray = `${p.tail} ${p.len + p.tail}`; + p.streak.style.strokeDashoffset = String(p.tail - at); + p.halo.setAttribute("cx", pt.x); + p.halo.setAttribute("cy", pt.y); + p.headDot.setAttribute("cx", pt.x); + p.headDot.setAttribute("cy", pt.y); + } + for (const [node, times] of Object.entries(arrivals)) { + let lit = 0; + for (const end of times) { + const since = t - end; + if (since >= 0 && since < 0.9) lit = Math.max(lit, 1 - since / 0.9); + } + flashes[node].style.opacity = (lit ** 1.5).toFixed(3); + } + }; + + if (reduce) { + // Static: the whole drawing, one moment of the flow lit. + for (const node of ["row1", "row2", "e1"]) + flashes[node].style.opacity = "0.6"; + } else { + sketch.dataset.pending = ""; + const reveal = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return; + reveal.disconnect(); + delete sketch.dataset.pending; + // Packets start once the lines have finished drawing. + setTimeout(() => whileVisible(sketch, frame, "0px"), 1100); + }, + { threshold: 0.35 }, + ); + reveal.observe(sketch); + } + } +} From a5d0816783eed990e61224cb97b5d109482428a0 Mon Sep 17 00:00:00 2001 From: Nikos Benakis Date: Thu, 12 Nov 2026 22:19:20 +0200 Subject: [PATCH 02/15] Landing page polish: strip generic patterns, tighten motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Section labels name the real thing in mono (BEGIN … COMMIT, webhook-signature, hookgres.deliveries) with the sketch's packet mark. - The colour glow behind the hero code window is now a shadow cast away from the cursor. - Remove fake window dots, the sketch's scroll-triggered draw-in, and the spotlight on quote cards that aren't clickable. - Hero streaks stay out of the text: right of the copy, above the feature list. - Chart caption for the time axis; rewrite the closing heading and the simulator's empty state. Co-Authored-By: Claude Opus 5.5 --- site/index.html | 124 ++++++++++++++++++++++++------------------------ site/motion.js | 74 ++++++++++++++++------------- 2 files changed, 102 insertions(+), 96 deletions(-) diff --git a/site/index.html b/site/index.html index f593ad2..9fdc35f 100644 --- a/site/index.html +++ b/site/index.html @@ -419,7 +419,12 @@ line-height: 1.1; letter-spacing: -0.03em; font-weight: 700; margin: 0; text-wrap: balance; } - .kicker { margin: 0 0 10px; font-size: 14px; font-weight: 500; color: var(--accent); } + /* Section labels name the real thing in mono, led by the same packet mark the sketch uses. */ + .kicker { + display: flex; align-items: center; gap: 10px; margin: 0 0 14px; + font: 500 12.5px/1.2 var(--mono); letter-spacing: 0; color: var(--ink-2); + } + .kicker::before { content: ""; flex: none; width: 14px; height: 6px; border-radius: 3px; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 16%, transparent); } .sub { font-size: 17px; line-height: 1.55; letter-spacing: -0.01em; color: var(--ink-2); margin: 16px 0 0; max-width: 34em; text-wrap: pretty; } .sub code { color: var(--ink); } .split { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(36px, 6vw, 80px); align-items: center; } @@ -447,8 +452,6 @@ /* Windows (delivery log, simulator) */ .window { background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); overflow: hidden; text-align: left; } .window-bar { display: flex; align-items: center; gap: 14px; padding: 12px 18px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--ink-2); } - .dots { display: flex; gap: 7px; } - .dots i { width: 10px; height: 10px; border-radius: 50%; background: var(--line); } .window-bar .title { font-weight: 500; color: var(--ink); } .window-bar .meta { margin-left: auto; font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--ink-3); font-variant-numeric: tabular-nums; } .log { padding: 6px 18px 12px; } @@ -565,6 +568,7 @@ /* Retry chart: attempts evenly spaced, elapsed time on a log scale, so the curve is the backoff. */ .chart { position: relative; height: 248px; } + .chart-caption { position: absolute; left: 24px; top: 4px; font-size: 12px; color: var(--ink-3); } .chart-plot { position: absolute; left: 64px; right: 28px; top: 44px; bottom: 34px; } .chart-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } .chart-svg path, .chart-svg line { vector-effect: non-scaling-stroke; } @@ -609,9 +613,10 @@ /* Too narrow for eight chips side by side: the dots carry the colour. */ .mark-chip { display: none; } .chart { height: 200px; } - .chart-plot { left: 44px; right: 16px; top: 20px; } + .chart-plot { left: 44px; right: 16px; top: 34px; } + .chart-caption { left: 10px; top: 0; font-size: 11.5px; } .chart-x { left: 44px; right: 16px; } - .chart-y .tick { left: 10px; top: calc(20px + (1 - var(--y)) * (100% - 54px)); font-size: 10px; } + .chart-y .tick { left: 10px; top: calc(34px + (1 - var(--y)) * (100% - 68px)); font-size: 10px; } .chart-x .tick { font-size: 10px; } .sim-track { padding: 14px 14px 10px 0; } .sim-status { padding: 14px 16px; } @@ -720,23 +725,23 @@ .hero-field { -webkit-mask-image: radial-gradient(ellipse 90% 50% at 50% 70%, #000 15%, transparent 75%); mask-image: radial-gradient(ellipse 90% 50% at 50% 70%, #000 15%, transparent 75%); } } - /* The code window floats: a light behind it drifts with the cursor and the card leans in. */ + /* The code window floats. The cursor is the light: the card leans toward it and its + shadow falls away from it, so depth comes from a shadow, not a glow. */ .hero-stage { position: relative; perspective: 1400px; min-width: 0; } - .hero-glow { - position: absolute; inset: 8% -6% -10%; z-index: -1; border-radius: 40px; pointer-events: none; - background: - radial-gradient(50% 55% at 35% 40%, color-mix(in oklch, var(--accent) 34%, transparent), transparent 70%), - radial-gradient(45% 50% at 70% 70%, color-mix(in oklch, oklch(70% 0.14 200) 26%, transparent), transparent 70%); + .hero-shadow { + position: absolute; inset: 10% 10% 28px; z-index: -1; border-radius: 28px; pointer-events: none; + box-shadow: 0 36px 80px -16px oklch(28% 0.09 var(--h) / 0.34), 0 80px 140px -50px oklch(40% 0.14 var(--h) / 0.3); transform: translate3d(var(--gx, 0px), var(--gy, 0px), 0); will-change: transform; } - :root[data-theme="dark"] .hero-glow { opacity: 0.7; } + /* A shadow can't show on true black, so dark mode keeps a faint tinted underlight. */ + :root[data-theme="dark"] .hero-shadow { box-shadow: 0 40px 110px -30px oklch(55% 0.16 var(--h) / 0.32); } .hero-code { transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); - transform-style: preserve-3d; will-change: transform; - box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.08), 0 1px 2px oklch(25% 0.05 var(--h) / 0.08), 0 24px 48px -12px oklch(25% 0.08 var(--h) / 0.28), 0 60px 120px -40px oklch(35% 0.12 var(--h) / 0.35); + will-change: transform; + box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.08), 0 0 0 1px oklch(25% 0.05 var(--h) / 0.06), 0 1px 2px oklch(25% 0.05 var(--h) / 0.1), 0 8px 20px -8px oklch(25% 0.08 var(--h) / 0.25); } - :root[data-theme="dark"] .hero-code { box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.08), 0 0 0 1px oklch(100% 0 0 / 0.1), 0 40px 100px -30px oklch(55% 0.16 var(--h) / 0.35); } + :root[data-theme="dark"] .hero-code { box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.08), 0 0 0 1px oklch(100% 0 0 / 0.1); } /* Cursor spotlight: the card's edge catches the light where the pointer is. */ [data-spot] { position: relative; } @@ -751,7 +756,7 @@ } [data-spot][data-lit]::after { opacity: 1; } .code[data-spot] { background: radial-gradient(520px circle at var(--mx, -500px) var(--my, -500px), oklch(100% 0 0 / 0.05), transparent 45%), var(--code-bg); } - .window[data-spot], .seen-card[data-spot] { background: radial-gradient(420px circle at var(--mx, -500px) var(--my, -500px), color-mix(in oklch, var(--accent) 5%, transparent), transparent 60%), var(--surface); } + .window[data-spot] { background: radial-gradient(420px circle at var(--mx, -500px) var(--my, -500px), color-mix(in oklch, var(--accent) 5%, transparent), transparent 60%), var(--surface); } /* Blueprint sketch */ .sketch-band { padding-top: clamp(48px, 6vw, 80px); } @@ -779,23 +784,16 @@ .sk-dot-warn { fill: var(--warn); } .sk-dot-bad { fill: var(--bad); } .sk-line { fill: none; stroke: color-mix(in oklch, var(--ink) 26%, transparent); stroke-width: 1.25; } - .sk-retry { stroke: color-mix(in oklch, var(--bad) 45%, transparent); stroke-dasharray: 0.008 0.008; } + .sk-retry { stroke: color-mix(in oklch, var(--bad) 45%, transparent); stroke-dasharray: 5 6; } .sk-streak { fill: none; stroke-width: 2.5; stroke-linecap: round; } .sk-head-halo { opacity: 0.22; } - /* First view: lines draw themselves and the boxes settle in, once. */ - .sk-line:not(.sk-retry) { stroke-dasharray: 1; } - .sk-line { transition: stroke-dashoffset 900ms var(--ease-out) 250ms, opacity 400ms var(--ease) 900ms; } - .sk-fade { transition: opacity 500ms var(--ease-out), transform 600ms var(--ease-out); transition-delay: calc(var(--d) * 60ms); } - .flow-sketch[data-pending] .sk-line:not(.sk-retry) { stroke-dashoffset: 1; } - .flow-sketch[data-pending] .sk-retry { opacity: 0; } - .flow-sketch[data-pending] .sk-fade { opacity: 0; transform: translateY(6px); } @media (max-width: 640px) { .flow-sketch { margin-top: 24px; border-radius: 16px; } } @media (prefers-reduced-motion: reduce) { - .hero-code, .hero-glow { transform: none; } - .mark-dot, .chart-head, .sk-line, .sk-fade, [data-spot]::after { transition: none; } + .hero-code, .hero-shadow { transform: none; } + .mark-dot, .chart-head, [data-spot]::after { transition: none; } .mark-chip .attempt { animation: none; } } @media (prefers-reduced-motion: reduce) { @@ -880,7 +878,7 @@

Webhooks that commit with your data.

- +
@@ -947,7 +945,7 @@

Webhooks that commit with your data.

-

How it flows

+

hookgres.send()

One commit in. Every endpoint, signed and retried.

send() writes a row next to your data. After COMMIT, workers claim it with SKIP LOCKED, sign the request and deliver it to each endpoint. A failure goes back on the schedule.

@@ -961,76 +959,76 @@

One commit in. Every endpoint, signed and retried.

- - - - - - - - - + + + + + + + + + - 500 · back on the schedule, 5s → 10h + 500 · back on the schedule, 5s → 10h - + Your app hookgres.send() - + Postgres one transaction BEGIN COMMIT - + invoices in_123 · status = paid - + hookgres.messages invoice.paid · 3 deliveries - FOR UPDATE SKIP LOCKED - + FOR UPDATE SKIP LOCKED + worker 1 - + worker 2 - + worker 3 - + acme.com 204 · first attempt - + shop.example 503 → 204 · attempt 3 - + crm.example @@ -1048,7 +1046,7 @@

One commit in. Every endpoint, signed and retried.

-

Transactional

+

BEGIN … COMMIT

Queued in your transaction. Sent only if it commits.

A webhook service is a second system. Call it after your commit, and a crash in between loses the event. Call it before, and a rollback leaves your customer with a webhook for an invoice that was never paid. Hookgres writes the webhook in the same transaction as your data, so you get the outbox and the delivery in one library.

Workers claim deliveries with FOR UPDATE SKIP LOCKED, so you can run as many as you like.

@@ -1082,7 +1080,7 @@

Queued in your transaction. Sent only if it commits.

-

The bug it fixes

+

ROLLBACK

A job queue can't roll back with your data.

The same payment code, run twice. First it queues the webhook with BullMQ inside the transaction: the payment rolls back, but the job is already in Redis, so acme.com marks in_124 paid while your database says open. Any queue outside Postgres does the same. Then the queue is swapped for hookgres.send(), and the webhook rolls back with the payment. Real BullMQ, Redis and Postgres, played at 2× speed.

@@ -1097,15 +1095,15 @@

A job queue can't roll back with your data.

Not a contrived example. Other teams hit these bugs in 2026:

    -
  • +
  • “…the entire batch is rolled back, but the webhook for the earlier (now rolled-back) request has already been dispatched.”
    Apache Fineract, March 2026
  • -
  • +
  • “REST API dispatches events for writes that were rolled back”
    NetBox, August 2026
  • -
  • +
  • “If that process dies — for any reason — the webhook dies with it.”
    Dodo Payments, January 2026
  • @@ -1119,7 +1117,7 @@

    Not a contrived example. Other teams hit these bugs in 2026:

    -

    Retries

    +

    retry schedule · 0s → 27.6h

    Watch it retry.

    Pick how your customer's server behaves and send an event. This runs the default schedule, with 28 hours squeezed into a few seconds.

    @@ -1136,9 +1134,10 @@

    Watch it retry.

    -
    invoice.paid
    +
    invoice.paid
    @@ -1172,7 +1171,7 @@

    Watch it retry.

    -

    Signatures

    +

    webhook-signature

    What your customers receive.

    A JSON envelope and three signed headers. The webhook-id stays the same across retries. Delivery is at least once, so receivers deduplicate on it.

    Verify in any language with the official Standard Webhooks libraries: JavaScript, Python, Go, Ruby, PHP, Rust, Java, C# and Elixir.

    @@ -1200,13 +1199,12 @@

    What your customers receive.

    -

    Delivery log

    +

    hookgres.deliveries

    Every attempt is a row you can query.

    Status, response body and timing for each request, stored next to your data. Build a customer-facing log, a support view or an alert with plain SQL.

    -
    -
      +
      • Replay. replayDelivery() re-sends a finished delivery with the same webhook-id, so receivers can deduplicate.
      • Idempotency keys. Pass the same key twice and get the original message back, not a duplicate.
      • Built for multi-tenant apps. Every event goes only to one tenant's endpoints, filtered by event type.
      • Crash recovery. If a worker dies mid-request, its claim expires and another worker picks it up.
      • -
      -
      -
      Failed in the last 24 hours
      -
      select e.tenant_id, e.url, d.last_error
      -from hookgres.deliveries d
      -join hookgres.endpoints e on e.id = d.endpoint_id
      -where d.status = 'failed'
      -  and d.updated_at > now() - interval '1 day';
      -
      -
    +
@@ -2024,8 +2036,8 @@

Your next webhook ships in your next commit.

} } - // Verify in any language: the tabs advance on their own while on screen (paused on - // hover or focus) until someone picks one. + // Verify in any language: the tabs step through every language once while on screen + // (paused on hover or focus), then rest on the first. Picking one stops it. { const verify = $(".verify"); const panels = [...verify.querySelectorAll(".verify-panel")]; @@ -2041,12 +2053,52 @@

Your next webhook ships in your next commit.

}, { threshold: 0.4 }).observe(verify); verify.addEventListener("animationend", (event) => { if (!event.target.classList.contains("tab-time") || !("auto" in verify.dataset)) return; + const next = (panels.findIndex((panel) => panel.hasAttribute("data-active")) + 1) % panels.length; driving = true; - pick((panels.findIndex((panel) => panel.hasAttribute("data-active")) + 1) % panels.length, false); + pick(next, false); driving = false; + if (next === 0) delete verify.dataset.auto; }); } } + + // SQL console: picking a tab runs that query; Replay walks one delivery through a replay. + { + const consoleCard = $(".console"); + const panels = [...consoleCard.querySelectorAll(".console-panel")]; + const copy = $(".bar .copy", consoleCard); + segmented($(".tabs", consoleCard), (_, index) => { + for (const [i, panel] of panels.entries()) panel.toggleAttribute("data-active", i === index); + copy.dataset.copyFrom = `sql-${index}`; + }); + const replay = $(".replay", consoleCard); + const row = replay.closest("tr"); + const foot = $(".console-foot", panels[0]); + const status = $(".c-status", row); + const count = $(".c-count", row); + const announce = $(".console-announce", consoleCard); + row.addEventListener("animationend", () => row.removeAttribute("data-flash")); + const pill = (value, tone) => `${value}`; + replay.addEventListener("click", () => { + replay.disabled = true; + replay.textContent = "Replaying"; + foot.innerHTML = `await hookgres.replayDelivery("${replay.dataset.id}")`; + status.innerHTML = pill("pending", "wait"); + count.textContent = "0"; + const later = (ms, fn) => setTimeout(fn, reduceMotion ? 0 : ms); + later(650, () => { + status.innerHTML = pill("delivering", "wait"); + count.textContent = "1"; + }); + later(1400, () => { + status.innerHTML = pill("succeeded", "ok"); + row.setAttribute("data-flash", ""); + replay.textContent = "Replayed"; + foot.textContent = "Delivered on the first try, with the original webhook-id so the receiver can deduplicate."; + announce.textContent = `Replayed ${replay.dataset.id}: succeeded. ${foot.textContent}`; + }); + }); + } diff --git a/site/motion.js b/site/motion.js index 06924d6..59e6ead 100644 --- a/site/motion.js +++ b/site/motion.js @@ -467,176 +467,4 @@ whileVisible(sketch, frame, "0px"); } } - - // Live delivery log: a new delivery arrives at the top every few seconds, its - // attempts tick in on the compressed schedule, and older rows slide down and out. - // Hovering the window or pressing Pause freezes it where it is. - const logWindow = document.querySelector(".log-window"); - if (logWindow && !reduce) { - const list = logWindow.querySelector(".log-rows"); - const toggle = logWindow.querySelector(".live-toggle"); - const idLabel = logWindow.querySelector(".log-id"); - // Five distinct endpoints in a fixed order, so neighbours never repeat. - const endpoints = [ - ["billing.example/in", [204]], - ["ops.example/alerts", [502, 204]], - ["crm.example/in", [500, 500, 500, 500, 500, 500, 500, 500]], - ["acme.com/webhooks", [204]], - ["shop.example/hooks", [503, 503, 204]], - ]; - const events = [ - "invoice.paid", - "customer.updated", - "subscription.renewed", - "invoice.paid", - "refund.created", - ]; - const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; - const msgId = () => - `msg_${Array.from({ length: 26 }, () => ALPHABET[Math.floor(Math.random() * 32)]).join("")}`; - const ARRIVE_EVERY = 3.4; - const TICK = 0.45; - const VISIBLE = 3; - let clock = 0; - let nextArrival = 1.2; - let n = 0; - let paused = false; - let hovered = false; - const active = []; - - const chip = (code, tone) => { - const el = document.createElement("span"); - el.className = `attempt ${tone} pop`; - el.textContent = code; - return el; - }; - const setStatus = (row, cls, text, small) => { - const status = row.querySelector(".status"); - status.className = `status ${cls}`; - status.textContent = text; - const s = document.createElement("small"); - s.textContent = small; - status.append(s); - }; - const sizeToRows = () => { - const rows = [...list.children].slice(0, VISIBLE); - list.style.height = `${rows.reduce((sum, r) => sum + r.offsetHeight, 0)}px`; - }; - - const arrive = () => { - const [url, codes] = endpoints[n % endpoints.length]; - const event = events[n % events.length]; - n++; - const row = document.createElement("div"); - row.className = "log-row"; - row.innerHTML = `
${'
'.repeat(8)}
`; - row.querySelector("b").textContent = url; - row.querySelector("small").textContent = event; - setStatus(row, "live", "Delivering", "attempt 1"); - idLabel.textContent = msgId(); - - // FLIP: note where the rows are, insert, then slide them from there. - const before = new Map( - [...list.children].map((r) => [r, r.getBoundingClientRect().top]), - ); - list.prepend(row); - while (list.children.length > VISIBLE + 1) list.lastElementChild.remove(); - for (const [r, top] of before) { - if (!r.isConnected) continue; - r.style.transition = "none"; - r.style.transform = `translateY(${top - r.getBoundingClientRect().top}px)`; - } - row.style.transition = "none"; - row.style.opacity = "0"; - row.style.transform = "translateY(-10px)"; - row.dataset.new = ""; - list.offsetHeight; - for (const r of list.children) { - r.style.transition = - "transform 560ms cubic-bezier(0.32, 0.72, 0, 1), opacity 320ms ease-out"; - r.style.transform = ""; - r.style.opacity = ""; - } - requestAnimationFrame(() => - requestAnimationFrame(() => delete row.dataset.new), - ); - active.push({ row, codes, i: 0, at: clock + 0.35 }); - }; - - const step = () => { - for (let k = active.length - 1; k >= 0; k--) { - const d = active[k]; - if (clock < d.at) continue; - if (!d.row.isConnected) { - active.splice(k, 1); - continue; - } - const code = d.codes[d.i]; - const last = d.i === d.codes.length - 1; - const tone = code < 300 ? "ok" : d.codes.at(-1) >= 300 ? "bad" : "warn"; - d.row.querySelectorAll(".slot")[d.i].append(chip(code, tone)); - d.i++; - if (last) { - const ok = code < 300; - setStatus( - d.row, - ok ? "ok" : "bad", - ok ? "Succeeded" : "Failed", - `${d.i} attempt${d.i > 1 ? "s" : ""}`, - ); - active.splice(k, 1); - } else { - setStatus(d.row, "live", "Retrying", `attempt ${d.i + 1} of 8`); - d.at = clock + TICK; - } - } - }; - - let last = 0; - whileVisible( - logWindow, - (now) => { - const dt = Math.min(0.1, (now - (last || now)) / 1000); - last = now; - if (paused || hovered) return; - clock += dt; - if (clock >= nextArrival) { - nextArrival = clock + ARRIVE_EVERY; - arrive(); - } - step(); - }, - "0px", - ); - - list.dataset.live = ""; - sizeToRows(); - let width = 0; - new ResizeObserver(([entry]) => { - if (Math.abs(entry.contentRect.width - width) < 1) return; - width = entry.contentRect.width; - sizeToRows(); - }).observe(logWindow); - toggle.hidden = false; - // A way to stop the moving content for everyone (WCAG 2.2.2). - toggle.addEventListener("click", () => { - paused = !paused; - toggle.toggleAttribute("data-paused", paused); - toggle.setAttribute( - "aria-label", - paused ? "Resume live updates" : "Pause live updates", - ); - toggle.querySelector(".live-label").textContent = paused - ? "Paused" - : "Live"; - }); - if (finePointer) { - logWindow.addEventListener("pointerenter", (event) => { - if (event.pointerType === "mouse") hovered = true; - }); - logWindow.addEventListener("pointerleave", () => { - hovered = false; - }); - } - } } From d0dd97243cf0d410fe6fe5f7c57f184a5cc87617 Mon Sep 17 00:00:00 2001 From: Nikos Benakis Date: Fri, 13 Nov 2026 15:11:04 +0200 Subject: [PATCH 08/15] Landing page: use cases, ORM tabs, endpoints today, one video section - New "when you need it" section after the hero: six customer requests from docs/use-cases, plus who doesn't need Hookgres. - Transaction demo gets pg / Prisma / Drizzle tabs; Commit and Roll back drive all three. Copy points to the ORM guide. - Waitlist and comparison table say how customers add endpoints today: your own settings page around createEndpoint(). - The install-to-retry recording moves into the rollback section as a second tab; its separate section is gone. Co-Authored-By: Claude Opus 5.5 --- site/index.html | 128 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 97 insertions(+), 31 deletions(-) diff --git a/site/index.html b/site/index.html index 27184f0..e60014f 100644 --- a/site/index.html +++ b/site/index.html @@ -367,6 +367,17 @@ /* Phones: smaller code so the longest line fits without scrolling sideways. */ @media (max-width: 440px) { .hero-code pre { font-size: 11px; padding: 14px 14px 18px; } } + /* When you need it: the customer requests that start it. */ + .cases { list-style: none; margin: 36px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } + .case { padding: 18px 20px 20px; border-radius: 14px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } + .case .ev { font: 500 12.5px/1.2 var(--mono); color: var(--accent); } + .case h3 { margin: 12px 0 0; font-size: 16px; line-height: 1.3; font-weight: 600; letter-spacing: -0.015em; color: var(--ink); } + .case p { margin: 6px 0 0; font-size: 14px; line-height: 1.45; color: var(--ink-2); text-wrap: pretty; } + .case .like { margin-top: 10px; font-size: 13px; color: var(--ink-3); } + .cases-note { margin: 24px 0 0; font-size: 14.5px; line-height: 1.5; color: var(--ink-3); max-width: 44em; text-wrap: pretty; } + @media (max-width: 900px) { .cases { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + @media (max-width: 560px) { .cases { grid-template-columns: minmax(0, 1fr); } } + /* What's in the box: six capabilities, one line each. */ .caps { list-style: none; margin: clamp(40px, 5vw, 56px) 0 0; padding: 0; @@ -493,6 +504,11 @@ .tx-demo[data-state="rollback"] .tx-end { background: oklch(70% 0.17 27 / 0.18); } .tx-demo[data-state="rollback"] .ln:not(.tx-end) { opacity: 0.38; } .tx-pick { margin-left: 0; } + .tx-orm .tab { font-family: var(--mono); font-size: 12px; } + @media (max-width: 400px) { .tx-demo .bar { padding-left: 8px; } .tx-demo .bar .tab { padding: 0 7px; } .tx-orm { margin-left: 0; } } + .tx-panels { display: grid; } + .tx-panel { grid-area: 1 / 1; min-width: 0; visibility: hidden; } + .tx-panel[data-active] { visibility: visible; } .tx-pick .tab[aria-pressed="true"] { color: var(--code-ink); background: oklch(100% 0 0 / 0.08); } /* Transaction diagram: both rows land together, then the worker delivers. */ @@ -752,6 +768,15 @@ /* Terminal recording: loads only near the viewport, plays only while visible. */ .demo-video-stage { position: relative; margin-top: 36px; } + .video-pick { display: inline-flex; gap: 2px; margin-top: 32px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--ink) 6%, var(--surface)); } + .video-pick button { position: relative; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; font: 500 13.5px/1 var(--sans); color: var(--ink-2); background: none; cursor: pointer; transition: color 150ms var(--ease), background-color 150ms var(--ease), box-shadow 150ms var(--ease); } + .video-pick button::before { content: ""; position: absolute; inset: -6px 0; } + .video-pick button[aria-selected="true"] { color: var(--ink); background: var(--surface); box-shadow: 0 1px 2px oklch(0% 0 0 / 0.12), inset 0 0 0 1px var(--line); } + @media (hover: hover) and (pointer: fine) { .video-pick button:hover { color: var(--ink); } } + .video-pick button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + .video-pick ~ .demo-video-stage { margin-top: 16px; } + .demo-video-stage[hidden] { display: none; } + .video-cap { margin: 14px 2px 0; font-size: 14.5px; line-height: 1.5; color: var(--ink-2); max-width: 52em; text-wrap: pretty; } .video-toggle { position: absolute; top: 10px; right: 12px; height: 28px; padding: 0 12px; border: 0; border-radius: 999px; font-family: inherit; font-size: 12.5px; font-weight: 500; line-height: 1; color: oklch(92% 0.01 258); background: oklch(100% 0 0 / 0.1); cursor: pointer; transition: background 150ms var(--ease); } @media (hover: hover) and (pointer: fine) { .video-toggle:hover { background: oklch(100% 0 0 / 0.18); } } .video-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } @@ -958,7 +983,7 @@ .mark-chip .attempt, .mark[data-state] .mark-dot::after { animation: none; } } @media (prefers-reduced-motion: reduce) { - .pill, .copy, .copy-btn, .copy-btn .glyph, .copy-btn .check, .search-trigger, .theme-toggle, .agent-link, .toast, .tab, .disclose .chev, .ln, .verify-panel, .console-panel, .replay { transition: none; } + .pill, .copy, .copy-btn, .copy-btn .glyph, .copy-btn .check, .search-trigger, .theme-toggle, .agent-link, .toast, .tab, .video-pick button, .disclose .chev, .ln, .verify-panel, .console-panel, .replay { transition: none; } .console-panel[data-active] tbody > *, .console-panel[data-active] tbody > [data-flash] { animation: none; } .full-prompt, .full-prompt-body { transition: none; } .live-dot::after, .tx-ep[data-s="ok"] .tx-pulse::after { animation: none; } @@ -1105,6 +1130,24 @@

Webhooks that commit with your data.

+
+
+

when you need it

+

It starts when a customer asks for webhooks.

+

“Can you tell our system when this happens, instead of us polling your API?” Six requests SaaS teams get, each answered with one send() in the transaction that saves the change.

+
    +
  • booking.created

    Bookings to their CRM

    A demo gets booked and lands in the customer’s sales pipeline.

  • +
  • invoice.paid

    Invoices to their ERP

    Their accounting system marks it paid only if your payment committed.

  • +
  • job.completed

    Long jobs, no polling

    One request when the job finishes, instead of one every few seconds.

  • +
  • form.submitted

    Zapier, Make and n8n

    One webhook URL setting connects your product to thousands of apps.

  • +
  • order.paid

    Orders to their warehouse

    Each merchant adds its own endpoints and picks the events it needs.

  • +
  • email.bounced

    Email events to their app

    High-volume events, where a per-message bill grows fastest.

  • +
+

Only receiving webhooks from Stripe, GitHub or Clerk? You don’t need Hookgres: use their verification library.

+

The six cases, with code

+
+
+

hookgres.send()

@@ -1218,13 +1261,14 @@

One commit in. Every endpoint, signed and retried.

BEGIN … COMMIT

Queued in your transaction. Sent only if it commits.

A webhook service is a second system. Call it after your commit, and a crash in between loses the event. Call it before, and a rollback leaves your customer with a webhook for an invoice that was never paid. Hookgres writes the webhook in the same transaction as your data, so you get the outbox and the delivery in one library.

-

Workers claim deliveries with FOR UPDATE SKIP LOCKED, so you can run as many as you like.

-

Sending webhooks

+

On an ORM, pass its transaction instead. Adapters for Prisma and Kysely ship with Hookgres; Drizzle, postgres.js, Knex, Sequelize, TypeORM and MikroORM each take a tested wrapper of a few lines.

+

Using your ORM’s transaction

-
billing.ts
-
await client.query("BEGIN");await client.query(
+          
+
+
await client.query("BEGIN");await client.query(
   `UPDATE invoices SET status = 'paid'
    WHERE id = $1`,
   [id],
@@ -1232,7 +1276,25 @@ 

Queued in your transaction. Sent only if it commits.

tenantId, eventType: "invoice.paid", payload: { id }, -}, { client });
await client.query("COMMIT");
+}, { client });await client.query("COMMIT");
+
import { fromPrisma } from "hookgres/prisma";
+await prisma.$transaction(async (tx) => {  await tx.invoice.update({
+    where: { id },
+    data: { status: "paid" },
+  });  await hookgres.send({
+    tenantId,
+    eventType: "invoice.paid",
+    payload: { id },
+  }, { client: fromPrisma(tx) });}); // returns, so Prisma commits
+
import { fromDrizzle } from "./db.js";
+await db.transaction(async (tx) => {  await tx.update(invoices)
+    .set({ status: "paid" })
+    .where(eq(invoices.id, id));  await hookgres.send({
+    tenantId,
+    eventType: "invoice.paid",
+    payload: { id },
+  }, { client: fromDrizzle(tx) });}); // returns, so Drizzle commits
+
-
-
-
-
-

npm install hookgres pg

-

Install it, send inside a transaction, watch it retry.

-

The published package, a real Postgres and a receiver that is down for two attempts. The rolled-back invoice sends nothing; the committed one is signed, retried and logged.

-
- Get started -
-
- - -
-
-
-

library vs. hosted service

@@ -1473,13 +1525,13 @@

A library, not a service.

Part of your transactionYesNo, a separate API call 1M webhooks a monthFree library, about $0.15 of storage$9.50 on Hookdeck Outpost, $115 on Svix Basic (compare) Where payloads goOnly to your customersAlso to the provider - Customer portalBuild on the delivery log, or join the waitlistOften included + Customer portalYour own settings page today (example), Pro portal on the waitlistOften included

Customer portal, coming to Hookgres Pro

-

Let your customers add endpoints, see every delivery and replay failures, styled to match your app. Join the waitlist for early access.

+

Today, customers add endpoints through your own settings page: a few routes around createEndpoint(), scoped to their tenant (example). Pro adds a portal where they add endpoints, see every delivery and replay failures, styled to match your app. Join the waitlist for early access.

@@ -1535,6 +1587,16 @@

Your next webhook ships in your next commit.

// Terminal recordings: fetch each only when it's about to scroll into view, and play // it only while it's on screen. Reduced motion or Save-Data: no autoplay, show controls. const manual = reduceMotion || navigator.connection?.saveData === true; + { + const pick = $(".video-pick"); + const stages = [...document.querySelectorAll(".video-pick ~ .demo-video-stage")]; + if (pick) segmented(pick, (_, index) => { + stages.forEach((stage, i) => { + stage.hidden = i !== index; + if (stage.hidden) $("video", stage)?.pause(); + }); + }); + } for (const stage of document.querySelectorAll(".demo-video-stage")) { const video = $("video", stage); const toggle = $(".video-toggle", stage); @@ -1788,7 +1850,11 @@

Your next webhook ships in your next commit.

// Transaction demo { const demo = $(".tx-demo"); - const word = $(".tx-word", demo); + const words = [...demo.querySelectorAll(".tx-word")]; + const panels = [...demo.querySelectorAll(".tx-panel")]; + segmented($(".tx-orm", demo), (_, index) => { + panels.forEach((panel, i) => { panel.toggleAttribute("data-active", i === index); }); + }); const rows = [...demo.querySelectorAll(".tx-row")]; const badge = $(".tx-badge", demo); const wire = $(".tx-wire", demo); @@ -1805,7 +1871,7 @@

Your next webhook ships in your next commit.

timers = []; for (const b of buttons) b.setAttribute("aria-pressed", String((b.dataset.tx === "commit") === commit)); demo.dataset.state = "idle"; - word.textContent = commit ? '"COMMIT"' : '"ROLLBACK"'; + for (const word of words) word.textContent = commit ? word.dataset.commit : word.dataset.rollback; badge.textContent = "BEGIN"; set(badge); for (const row of rows) set(row); From bd05a9431be13dafa3f52de4a16f0a0aa6750f36 Mon Sep 17 00:00:00 2001 From: Nikos Benakis Date: Fri, 13 Nov 2026 15:17:17 +0200 Subject: [PATCH 09/15] Landing page: polish pass Curly apostrophes in the copy, one 0.96 press scale everywhere (tabs included), neutral focus rings, and equal-height video captions so switching recordings doesn't move the page. Co-Authored-By: Claude Opus 5.5 --- site/index.html | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/site/index.html b/site/index.html index e60014f..9f32da6 100644 --- a/site/index.html +++ b/site/index.html @@ -153,7 +153,7 @@ } a { color: var(--accent); text-decoration: none; } @media (hover: hover) and (pointer: fine) { a:hover { text-decoration: underline; text-decoration-thickness: from-font; text-underline-offset: 3px; } } - :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } + :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 4px; } ::selection { background: color-mix(in oklch, var(--accent) 22%, transparent); } code { font-family: var(--mono); font-size: 0.88em; letter-spacing: 0; } button { touch-action: manipulation; -webkit-user-select: none; user-select: none; } @@ -190,7 +190,7 @@ font: 500 11px/1 var(--sans); letter-spacing: 0.02em; color: var(--ink-3); padding: 4px 6px; border-radius: 6px; background: var(--material); box-shadow: var(--raised); } - .search-trigger:active { transform: scale(0.98); } + .search-trigger:active { transform: scale(0.96); } .theme-toggle { position: relative; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 999px; cursor: pointer; @@ -199,7 +199,7 @@ } .theme-toggle::before { content: ""; position: absolute; inset: -6px; } .theme-toggle svg { width: 17px; height: 17px; } - .theme-toggle:active { transform: scale(0.94); } + .theme-toggle:active { transform: scale(0.96); } @media (hover: hover) and (pointer: fine) { .theme-toggle:hover { color: var(--ink); background: color-mix(in oklch, var(--ink) 7%, transparent); } } :root[data-theme="dark"] .theme-toggle .sun, :root:not([data-theme="dark"]) .theme-toggle .moon { display: none; } @media (hover: hover) and (pointer: fine) { .search-trigger:hover { color: var(--ink-2); background: color-mix(in oklch, var(--ink) 7%, transparent); } } @@ -258,7 +258,7 @@ min-width: 62px; position: relative; } .copy::before { content: ""; position: absolute; inset: -8px -2px; } - .copy:active { transform: scale(0.97); } + .copy:active { transform: scale(0.96); } .copy[data-copied] { color: var(--ok); } @media (hover: hover) and (pointer: fine) { .copy:hover { background: color-mix(in oklch, var(--ink) 7%, transparent); color: var(--ink); } } /* Copy-paste starts: an agent prompt and the npm command. */ @@ -282,7 +282,7 @@ transition: background-color 150ms var(--ease), transform 150ms var(--ease-out); } .agent-link::before { content: ""; position: absolute; inset: -4px -1px; } - .agent-link:active { transform: scale(0.9); } + .agent-link:active { transform: scale(0.96); } @media (hover: hover) and (pointer: fine) { .agent-link:hover { background: color-mix(in oklch, var(--ink) 7%, transparent); } } /* Confirms the click: the prompt is also on the clipboard in case the app doesn't open. */ .toast { @@ -324,7 +324,7 @@ .copy-btn .check { opacity: 0; transform: scale(0.6); color: var(--ok); } .copy-btn[data-copied] .glyph { opacity: 0; transform: scale(0.6); } .copy-btn[data-copied] .check { opacity: 1; transform: scale(1); } - .copy-btn:active { transform: scale(0.94); } + .copy-btn:active { transform: scale(0.96); } .copy-btn[data-failed] { color: var(--bad); } @media (hover: hover) and (pointer: fine) { .copy-btn:hover { background: color-mix(in oklch, var(--ink) 7%, transparent); color: var(--ink); } } @@ -342,7 +342,7 @@ } .code .bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 0 8px 0 18px; border-bottom: 1px solid var(--code-line); font-size: 12.5px; color: var(--code-dim); } .code .copy { color: var(--code-dim); } - .code .copy:focus-visible, .code .copy-btn:focus-visible, .code .tab:focus-visible { outline-color: var(--code-key); } + .code .copy:focus-visible, .code .copy-btn:focus-visible, .code .tab:focus-visible { outline-color: var(--code-ink); } .code .copy-btn { color: var(--code-dim); } @media (hover: hover) and (pointer: fine) { .code .copy:hover, .code .copy-btn:hover { background: oklch(100% 0 0 / 0.08); color: var(--code-ink); } @@ -355,9 +355,10 @@ .tab { border: 0; background: none; cursor: pointer; height: 30px; padding: 0 10px; border-radius: 7px; font: 500 12.5px/1 var(--sans); letter-spacing: 0; color: var(--code-dim); - transition: color 150ms var(--ease), background-color 150ms var(--ease); + transition: color 150ms var(--ease), background-color 150ms var(--ease), transform 150ms var(--ease-out); } .tab { position: relative; } + .tab:active, .video-pick button:active { transform: scale(0.96); } .tab::before { content: ""; position: absolute; inset: -7px 0; } .tab[aria-selected="true"] { color: var(--code-ink); background: oklch(100% 0 0 / 0.08); } @media (hover: hover) and (pointer: fine) { .tab:hover { color: var(--code-ink); } } @@ -750,7 +751,7 @@ } .replay::before { content: ""; position: absolute; inset: -8px -4px; } .replay:active { transform: scale(0.96); } - .replay:focus-visible { outline-color: var(--code-key); } + .replay:focus-visible { outline-color: var(--code-ink); } .replay:disabled { cursor: default; color: var(--code-dim); background: oklch(100% 0 0 / 0.05); } @media (hover: hover) and (pointer: fine) { .replay:not(:disabled):hover { background: oklch(100% 0 0 / 0.16); } } .console-foot { margin: 0; padding: 12px 20px 14px; font: 12px/1.5 var(--mono); letter-spacing: 0; color: var(--code-dim); } @@ -769,17 +770,18 @@ /* Terminal recording: loads only near the viewport, plays only while visible. */ .demo-video-stage { position: relative; margin-top: 36px; } .video-pick { display: inline-flex; gap: 2px; margin-top: 32px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--ink) 6%, var(--surface)); } - .video-pick button { position: relative; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; font: 500 13.5px/1 var(--sans); color: var(--ink-2); background: none; cursor: pointer; transition: color 150ms var(--ease), background-color 150ms var(--ease), box-shadow 150ms var(--ease); } + .video-pick button { position: relative; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; font: 500 13.5px/1 var(--sans); color: var(--ink-2); background: none; cursor: pointer; transition: color 150ms var(--ease), background-color 150ms var(--ease), box-shadow 150ms var(--ease), transform 150ms var(--ease-out); } .video-pick button::before { content: ""; position: absolute; inset: -6px 0; } .video-pick button[aria-selected="true"] { color: var(--ink); background: var(--surface); box-shadow: 0 1px 2px oklch(0% 0 0 / 0.12), inset 0 0 0 1px var(--line); } @media (hover: hover) and (pointer: fine) { .video-pick button:hover { color: var(--ink); } } - .video-pick button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + .video-pick button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } .video-pick ~ .demo-video-stage { margin-top: 16px; } .demo-video-stage[hidden] { display: none; } - .video-cap { margin: 14px 2px 0; font-size: 14.5px; line-height: 1.5; color: var(--ink-2); max-width: 52em; text-wrap: pretty; } + .video-cap { min-height: 3lh; margin: 14px 2px 0; font-size: 14.5px; line-height: 1.5; color: var(--ink-2); max-width: 52em; text-wrap: pretty; } + @media (max-width: 560px) { .video-cap { min-height: 5lh; } } .video-toggle { position: absolute; top: 10px; right: 12px; height: 28px; padding: 0 12px; border: 0; border-radius: 999px; font-family: inherit; font-size: 12.5px; font-weight: 500; line-height: 1; color: oklch(92% 0.01 258); background: oklch(100% 0 0 / 0.1); cursor: pointer; transition: background 150ms var(--ease); } @media (hover: hover) and (pointer: fine) { .video-toggle:hover { background: oklch(100% 0 0 / 0.18); } } - .video-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + .video-toggle:focus-visible { outline: 2px solid oklch(96% 0 0); outline-offset: 2px; } .video-toggle[hidden] { display: none; } .demo-video-stage video { display: block; width: 100%; height: auto; aspect-ratio: 1240 / 860; border-radius: 14px; background: oklch(17% 0.01 258); box-shadow: var(--shadow); } @@ -806,7 +808,7 @@ transition: border-color 150ms var(--ease); } .waitlist-form input::placeholder { color: var(--ink-3); } - .waitlist-form input:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 18%, transparent); } + .waitlist-form input:focus-visible { outline: none; border-color: var(--ink-2); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ink) 12%, transparent); } .waitlist-form input[aria-invalid="true"] { border-color: var(--bad); } .waitlist .waitlist-note[data-error] { color: var(--bad); } .waitlist .waitlist-note { margin-top: 12px; font-size: 13.5px; color: var(--ink-3); } @@ -1247,7 +1249,7 @@

One commit in. Every endpoint, signed and retried.

- +
@@ -1320,7 +1322,7 @@

Queued in your transaction. Sent only if it commits.

ROLLBACK

-

A job queue can't roll back with your data.

+

A job queue can’t roll back with your data.

A queue outside Postgres keeps a job your transaction rolled back, so your customer hears about a payment that never happened. Two terminal recordings with real Postgres and the published package.

How it works @@ -1331,7 +1333,7 @@

A job queue can't roll back with your data.

-

The same payment code, run twice. With BullMQ inside the transaction, the payment rolls back but the job is already in Redis, so acme.com marks in_124 paid while your database says open. With hookgres.send() in its place, the webhook rolls back with the payment.

+

The same payment code, run twice. With BullMQ, the payment rolls back but the job is already in Redis, so acme.com marks in_124 paid. With hookgres.send(), the webhook rolls back with the payment.

Animation of one event sent to three endpoints. The healthy one answers 204 on the first attempt. The flaky one answers 503 twice, then 204 on the third attempt, 5 minutes in. The one that is down answers 500 to all eight attempts over 27.6 hours and the delivery fails.

-

A server that's down never holds up the others: every endpoint gets its own delivery, retries and log rows.

+

A server that’s down never holds up the others: every endpoint gets its own delivery, retries and log rows.

@@ -1394,7 +1396,7 @@

Watch it retry.

standard webhooks

Your customers verify it with code they already have.

Every request is signed to the open Standard Webhooks spec. Your customers check it with an official library in their own language, so you never design, document or support a signing scheme of your own.

-

Official libraries for JavaScript, Python, Go, Ruby, PHP, Rust, Java, C# and Elixir. Hookgres's tests check its signatures against the JavaScript one.

+

Official libraries for JavaScript, Python, Go, Ruby, PHP, Rust, Java, C# and Elixir. Hookgres’s tests check its signatures against the JavaScript one.

Verifying webhooks

@@ -1507,7 +1509,7 @@

Every attempt is a row you can query.

  • Replay. replayDelivery() re-sends a finished delivery with the same webhook-id, so receivers can deduplicate.
  • Idempotency keys. Pass the same key twice and get the original message back, not a duplicate.
  • -
  • Built for multi-tenant apps. Every event goes only to one tenant's endpoints, filtered by event type.
  • +
  • Built for multi-tenant apps. Every event goes only to one tenant’s endpoints, filtered by event type.
  • Crash recovery. If a worker dies mid-request, its claim expires and another worker picks it up.
@@ -1517,7 +1519,7 @@

Every attempt is a row you can query.

library vs. hosted service

A library, not a service.

-

Hosted services like Svix and Hookdeck are a great fit when you want someone else to run it. Hookgres fits when you'd rather keep webhooks next to your data.

+

Hosted services like Svix and Hookdeck are a great fit when you want someone else to run it. Hookgres fits when you’d rather keep webhooks next to your data.

From d30125d35440519041a376b1e77f9c9d395db1c9 Mon Sep 17 00:00:00 2001 From: Nikos Benakis Date: Fri, 13 Nov 2026 15:29:43 +0200 Subject: [PATCH 10/15] Landing page: section type, smooth seams, agent tooltips, live cards - Section labels larger and in the accent colour; headings get a measure; body copy steps up to 18px. - Dark bands fade with eased stops into the colour of the section they meet (the night band now meets the alt section without a line), and the alt section fades into the page. - Agent row: a one-line pitch, and tooltips on the agent logos and copy buttons replace the bottom toast. - Use-case cards: brand logos, whole-card links to each case, and a request line that delivers once on screen. - Bug quotes: source cards with logo, tracker id, date, highlighted phrase and a link out. Co-Authored-By: Claude Opus 5.5 --- site/index.html | 246 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 185 insertions(+), 61 deletions(-) diff --git a/site/index.html b/site/index.html index 9f32da6..657d493 100644 --- a/site/index.html +++ b/site/index.html @@ -282,18 +282,24 @@ transition: background-color 150ms var(--ease), transform 150ms var(--ease-out); } .agent-link::before { content: ""; position: absolute; inset: -4px -1px; } + /* Tooltips: the agent's name on hover, then what the click did. */ + [data-tip]::after { + content: attr(data-tip); position: absolute; bottom: calc(100% + 8px); left: 50%; z-index: 5; + padding: 6px 9px; border-radius: 8px; background: var(--ink); color: var(--bg); + font: 500 12px/1.2 var(--sans); letter-spacing: 0; white-space: nowrap; + box-shadow: 0 0 0 1px oklch(100% 0 0 / 0.1), 0 8px 20px -8px oklch(20% 0.05 var(--h) / 0.35); + opacity: 0; transform: translate(-50%, 4px); pointer-events: none; + transition: opacity 150ms var(--ease), transform 200ms var(--ease-out); + } + .copy-btn[data-tip]::after { left: auto; right: 0; transform: translateY(4px); } + .code .copy-btn[data-tip]::after { bottom: auto; top: calc(100% + 6px); transform: translateY(-4px); } + @media (hover: hover) and (pointer: fine) { [data-tip]:hover::after { opacity: 1; transform: translate(-50%, 0); transition-delay: 200ms; } } + [data-tip]:focus-visible::after, [data-tip][data-tip-on]::after { opacity: 1; transform: translate(-50%, 0); transition-delay: 0ms; } + @media (hover: hover) and (pointer: fine) { .copy-btn[data-tip]:hover::after { transform: none; } } + .copy-btn[data-tip]:focus-visible::after, .copy-btn[data-tip][data-tip-on]::after { transform: none; } + .start-pitch { color: var(--ink-2); } .agent-link:active { transform: scale(0.96); } @media (hover: hover) and (pointer: fine) { .agent-link:hover { background: color-mix(in oklch, var(--ink) 7%, transparent); } } - /* Confirms the click: the prompt is also on the clipboard in case the app doesn't open. */ - .toast { - position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom)); z-index: 50; - max-width: calc(100vw - 32px); padding: 10px 16px; border-radius: 12px; - background: var(--material); box-shadow: var(--raised), 0 12px 32px -12px oklch(25% 0.05 var(--h) / 0.25); - font-size: 13.5px; line-height: 1.45; color: var(--ink); text-align: center; - opacity: 0; transform: translate(-50%, 8px); pointer-events: none; - transition: opacity 200ms var(--ease-out), transform 300ms var(--ease-out); - } - .toast[data-show] { opacity: 1; transform: translate(-50%, 0); } .disclose { font: inherit; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; position: relative; @@ -369,12 +375,39 @@ @media (max-width: 440px) { .hero-code pre { font-size: 11px; padding: 14px 14px 18px; } } /* When you need it: the customer requests that start it. */ - .cases { list-style: none; margin: 36px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } - .case { padding: 18px 20px 20px; border-radius: 14px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } - .case .ev { font: 500 12.5px/1.2 var(--mono); color: var(--accent); } - .case h3 { margin: 12px 0 0; font-size: 16px; line-height: 1.3; font-weight: 600; letter-spacing: -0.015em; color: var(--ink); } - .case p { margin: 6px 0 0; font-size: 14px; line-height: 1.45; color: var(--ink-2); text-wrap: pretty; } - .case .like { margin-top: 10px; font-size: 13px; color: var(--ink-3); } + .cases { list-style: none; margin: 40px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } + .case { + position: relative; display: flex; flex-direction: column; padding: 20px; border-radius: 14px; + background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); + transition: box-shadow 150ms var(--ease); + } + .case-top { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-3); } + .case-top .brand { width: 18px; height: 18px; color: var(--ink); } + .case-top .brand-wide { width: 60px; height: 12px; margin-left: 2px; } + .case-top .go { width: 16px; height: 16px; margin-left: auto; transition: transform 200ms var(--ease-out), color 150ms var(--ease); } + .case h3 { margin: 16px 0 0; font-size: 16.5px; line-height: 1.3; font-weight: 600; letter-spacing: -0.015em; } + .case h3 a { color: var(--ink); } + .case h3 a:focus-visible { outline: none; } + .case:has(h3 a:focus-visible) { outline: 2px solid var(--ink); outline-offset: 2px; } + .case p { margin: 6px 0 18px; font-size: 14px; line-height: 1.5; color: var(--ink-2); text-wrap: pretty; } + /* A one-line request: the event goes out once the card is on screen, then the endpoint answers. */ + .case-req { + position: relative; overflow: hidden; margin-top: auto; display: flex; align-items: center; gap: 8px; + padding: 8px 8px 8px 10px; border-radius: 9px; font: 500 12px/1 var(--mono); + background: color-mix(in srgb, var(--ink) 3.5%, var(--surface)); box-shadow: inset 0 0 0 1px var(--line); + } + .case-req .m { color: var(--ink-3); } + .case-req .ev { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .case-req .code { margin-left: auto; padding: 4px 6px; border-radius: 5px; color: var(--ok); background: color-mix(in oklch, var(--ok) 13%, transparent); font-variant-numeric: tabular-nums; } + .case-req::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--ok); transform-origin: left; transform: scaleX(0); opacity: 0; } + .cases[data-arm] .case-req .code { opacity: 0; transform: scale(0.7); } + .cases[data-arm][data-live] .case-req::after { animation: req-run 760ms var(--ease) both; animation-delay: calc(var(--i) * 110ms); } + .cases[data-arm][data-live] .case-req .code { opacity: 1; transform: none; transition: opacity 200ms var(--ease), transform 300ms var(--ease-out); transition-delay: calc(var(--i) * 110ms + 640ms); } + @keyframes req-run { 0% { transform: scaleX(0); opacity: 1; } 75% { transform: scaleX(1); opacity: 1; } 100% { transform: scaleX(1); opacity: 0; } } + @media (hover: hover) and (pointer: fine) { + .case:hover { box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 22%, transparent), 0 12px 28px -18px oklch(25% 0.05 var(--h) / 0.35); } + .case:hover .go { color: var(--ink); transform: translate(2px, -2px); } + } .cases-note { margin: 24px 0 0; font-size: 14.5px; line-height: 1.5; color: var(--ink-3); max-width: 44em; text-wrap: pretty; } @media (max-width: 900px) { .cases { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 560px) { .cases { grid-template-columns: minmax(0, 1fr); } } @@ -426,20 +459,21 @@ /* Sections */ section.band { padding: clamp(64px, 8vw, 104px) 0; } - section.alt { background: var(--bg-alt); } + section.alt { background: linear-gradient(var(--bg-alt) calc(100% - 200px), color-mix(in oklab, var(--bg-alt) 50%, var(--bg)) calc(100% - 100px), var(--bg)); } h2 { font-family: var(--display); font-size: clamp(30px, 1rem + 2vw, 42px); line-height: 1.1; letter-spacing: -0.03em; font-weight: 700; - margin: 0; text-wrap: balance; + margin: 0; max-width: 18em; text-wrap: balance; } /* Section labels name the real thing in mono, led by the same packet mark the sketch uses. */ .kicker { - display: flex; align-items: center; gap: 10px; margin: 0 0 14px; - font: 500 12.5px/1.2 var(--mono); letter-spacing: 0; color: var(--ink-2); + display: flex; align-items: center; gap: 10px; margin: 0 0 18px; + font: 500 13.5px/1.2 var(--mono); letter-spacing: 0; color: var(--accent); } .kicker::before { content: ""; flex: none; width: 14px; height: 6px; border-radius: 3px; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 16%, transparent); } - .sub { font-size: 17px; line-height: 1.55; letter-spacing: -0.01em; color: var(--ink-2); margin: 16px 0 0; max-width: 34em; text-wrap: pretty; } + .sub { font-size: 18px; line-height: 1.55; letter-spacing: -0.012em; color: var(--ink-2); margin: 18px 0 0; max-width: 36em; text-wrap: pretty; } + @media (max-width: 640px) { .sub { font-size: 17px; } } .sub code { color: var(--ink); } .split { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(36px, 6vw, 80px); align-items: center; } .split.flip { grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); } @@ -449,14 +483,41 @@ .seen { margin-top: clamp(32px, 4vw, 48px); } .seen h3 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.01em; color: var(--ink); text-wrap: balance; } .seen ul { list-style: none; margin: 16px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } - .seen-card { display: flex; flex-direction: column; justify-content: space-between; gap: 14px; padding: 18px 20px; border-radius: 12px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } - .seen blockquote { margin: 0; font-size: 15px; line-height: 1.5; color: var(--ink); text-wrap: pretty; } - .seen-src { font-size: 13.5px; color: var(--ink-3); } - .seen-src:hover { color: var(--ink); } + .seen-card { + position: relative; display: flex; flex-direction: column; gap: 16px; padding: 20px; border-radius: 14px; + background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); + transition: box-shadow 150ms var(--ease); + } + .seen-top { display: flex; align-items: center; gap: 12px; } + .seen-logo { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; color: var(--ink); background: color-mix(in srgb, var(--ink) 4%, var(--surface)); box-shadow: inset 0 0 0 1px var(--line); } + .seen-logo .brand { width: 18px; height: 18px; } + .seen-who { display: grid; gap: 2px; min-width: 0; } + .seen-who b { font-size: 14.5px; font-weight: 600; letter-spacing: -0.01em; color: var(--ink); } + .seen-who span { font: 12px/1.3 var(--mono); color: var(--ink-3); } + .seen-date { margin-left: auto; align-self: start; font-size: 12.5px; color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; } + .seen blockquote { margin: 0; font-size: 15.5px; line-height: 1.55; color: var(--ink); text-wrap: pretty; } + /* The damning part of each quote, underlined like a reviewer's highlighter. */ + .seen mark { + color: inherit; padding: 0 1px; border-radius: 2px; + background: linear-gradient(color-mix(in oklch, var(--bad) 24%, transparent), color-mix(in oklch, var(--bad) 24%, transparent)) no-repeat 0 88% / 100% 0.42em; + } + .seen ul[data-arm] mark { background-size: 0% 0.42em; } + .seen ul[data-arm][data-live] mark { background-size: 100% 0.42em; transition: background-size 700ms var(--ease-out); transition-delay: calc(var(--i) * 160ms + 250ms); } + .seen-src { margin-top: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--ink-3); } + .seen-src::after { content: ""; position: absolute; inset: 0; border-radius: inherit; } + .seen-src .go { width: 14px; height: 14px; transition: transform 200ms var(--ease-out); } + .seen-src:focus-visible { outline: none; } + .seen-card:has(.seen-src:focus-visible) { outline: 2px solid var(--ink); outline-offset: 2px; } + @media (hover: hover) and (pointer: fine) { + .seen-card:hover { box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 22%, transparent), 0 12px 28px -18px oklch(25% 0.05 var(--h) / 0.35); } + .seen-card:hover .seen-src { color: var(--ink); } + .seen-card:hover .go { transform: translate(2px, -2px); } + } @media (max-width: 760px) { .seen ul { grid-template-columns: minmax(0, 1fr); } } .head-row { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 20px 40px; } .more { margin: 20px 0 0; font-size: 15.5px; font-weight: 500; } .more a::after { content: " ›"; } + .case h3 a::after { content: ""; position: absolute; inset: 0; border-radius: inherit; } .points { list-style: none; margin: 24px 0 0; padding: 0; display: grid; gap: 14px; } ul.points li { padding-left: 18px; position: relative; font-size: 15px; line-height: 1.5; color: var(--ink-2); } ul.points li::before { content: ""; position: absolute; left: 0; top: 0.62em; width: 6px; height: 6px; border-radius: 50%; background: var(--line); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 18%, transparent); } @@ -865,8 +926,19 @@ mask-image: linear-gradient(transparent, #000 220px, #000 calc(100% - 220px), transparent); } /* Seams: the page eases into the band and back out instead of switching at a hard edge. */ - .night-in { background: linear-gradient(var(--page-bg), var(--night) 200px); padding-top: clamp(120px, 12vw, 168px); } - .night-out { background: linear-gradient(var(--night) calc(100% - 200px), var(--page-bg)); padding-bottom: clamp(120px, 12vw, 168px); } + /* Eased stops (no visible start or end line), and each band fades into the colour of the section it meets. */ + :root { --page-bg-alt: var(--bg-alt); } + .night-in { + --from: var(--page-bg); + background: linear-gradient(var(--from), color-mix(in oklab, var(--night) 3%, var(--from)) 30px, color-mix(in oklab, var(--night) 12%, var(--from)) 64px, color-mix(in oklab, var(--night) 32%, var(--from)) 100px, color-mix(in oklab, var(--night) 68%, var(--from)) 140px, color-mix(in oklab, var(--night) 88%, var(--from)) 176px, color-mix(in oklab, var(--night) 97%, var(--from)) 210px, var(--night) 240px); + padding-top: clamp(128px, 13vw, 184px); + } + .night-out { + --to: var(--page-bg); + background: linear-gradient(var(--night) calc(100% - 240px), color-mix(in oklab, var(--night) 97%, var(--to)) calc(100% - 210px), color-mix(in oklab, var(--night) 88%, var(--to)) calc(100% - 176px), color-mix(in oklab, var(--night) 68%, var(--to)) calc(100% - 140px), color-mix(in oklab, var(--night) 32%, var(--to)) calc(100% - 100px), color-mix(in oklab, var(--night) 12%, var(--to)) calc(100% - 64px), color-mix(in oklab, var(--night) 3%, var(--to)) calc(100% - 30px), var(--to)); + padding-bottom: clamp(128px, 13vw, 184px); + } + .night-out.to-alt { --to: var(--page-bg-alt); } .night .kicker::before { box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 22%, transparent), 0 0 14px color-mix(in oklch, var(--accent) 60%, transparent); } /* Hero field: a dot grid that leans away from the cursor, with webhooks crossing it. @@ -985,7 +1057,7 @@ .mark-chip .attempt, .mark[data-state] .mark-dot::after { animation: none; } } @media (prefers-reduced-motion: reduce) { - .pill, .copy, .copy-btn, .copy-btn .glyph, .copy-btn .check, .search-trigger, .theme-toggle, .agent-link, .toast, .tab, .video-pick button, .disclose .chev, .ln, .verify-panel, .console-panel, .replay { transition: none; } + .pill, .copy, .copy-btn, [data-tip]::after, .copy-btn .glyph, .copy-btn .check, .search-trigger, .theme-toggle, .agent-link, .tab, .video-pick button, .disclose .chev, .ln, .verify-panel, .console-panel, .replay { transition: none; } .console-panel[data-active] tbody > *, .console-panel[data-active] tbody > [data-flash] { animation: none; } .full-prompt, .full-prompt-body { transition: none; } .live-dot::after, .tx-ep[data-s="ok"] .tx-pulse::after { animation: none; } @@ -1038,11 +1110,11 @@

Webhooks that commit with your data.

Agent

Add Hookgres webhooks to this app: hookgres.dev/llms.txt

- -

Open inOpen in Claude CodeOpen in CodexOpen in Cursor

+

One line, and your agent wires it in.Open inOpen in Claude CodeOpen in CodexOpen in Cursor

@@ -1053,7 +1125,7 @@

Webhooks that commit with your data.

npm

npm install hookgres pg

- @@ -1074,7 +1146,7 @@

Webhooks that commit with your data.

- @@ -1135,15 +1207,45 @@

Webhooks that commit with your data.

when you need it

-

It starts when a customer asks for webhooks.

-

“Can you tell our system when this happens, instead of us polling your API?” Six requests SaaS teams get, each answered with one send() in the transaction that saves the change.

+

“Can you tell our system when this happens?”

+

When a customer asks that, you need webhooks. Six requests SaaS teams get most, each answered with one send() inside the transaction that saves the change.

    -
  • booking.created

    Bookings to their CRM

    A demo gets booked and lands in the customer’s sales pipeline.

  • -
  • invoice.paid

    Invoices to their ERP

    Their accounting system marks it paid only if your payment committed.

  • -
  • job.completed

    Long jobs, no polling

    One request when the job finishes, instead of one every few seconds.

  • -
  • form.submitted

    Zapier, Make and n8n

    One webhook URL setting connects your product to thousands of apps.

  • -
  • order.paid

    Orders to their warehouse

    Each merchant adds its own endpoints and picks the events it needs.

  • -
  • email.bounced

    Email events to their app

    High-volume events, where a per-message bill grows fastest.

  • +
  • +
    LikeCal.com
    +

    Bookings to their CRM

    +

    A demo gets booked and lands in the customer’s sales pipeline.

    + +
  • +
  • +
    LikeStripe
    +

    Invoices to their ERP

    +

    Their accounting system marks it paid only if your payment committed.

    + +
  • +
  • +
    LikeReplicate
    +

    Long jobs, no polling

    +

    One request when the job finishes, instead of one every few seconds.

    + +
  • +
  • +
    LikeTally
    +

    Zapier, Make and n8n

    +

    One webhook URL setting connects your product to thousands of apps.

    + +
  • +
  • +
    LikeShopify
    +

    Orders to their warehouse

    +

    Each merchant adds its own endpoints and picks the events it needs.

    + +
  • +
  • +
    LikeResend
    +

    Email events to their app

    +

    High-volume events, where a per-message bill grows fastest.

    + +

Only receiving webhooks from Stripe, GitHub or Clerk? You don’t need Hookgres: use their verification library.

The six cases, with code

@@ -1345,17 +1447,20 @@

A job queue can’t roll back with your data.

Not a contrived example. Other teams hit these bugs in 2026:

    -
  • -
    “…the entire batch is rolled back, but the webhook for the earlier (now rolled-back) request has already been dispatched.”
    - Apache Fineract, March 2026 +
  • +
    Apache FineractJira · FINERACT-2570Mar 2026
    +
    “…the entire batch is rolled back, but the webhook for the earlier (now rolled-back) request has already been dispatched.”
    + Read it on issues.apache.org
  • -
  • -
    “REST API dispatches events for writes that were rolled back”
    - NetBox, August 2026 +
  • +
    NetBoxGitHub · #22978Aug 2026
    +
    “REST API dispatches events for writes that were rolled back”
    + Read it on github.com
  • -
  • -
    “If that process dies — for any reason — the webhook dies with it.”
    - Dodo Payments, January 2026 +
  • +
    Dodo PaymentsEngineering blogJan 2026
    +
    “If that process dies — for any reason — the webhook dies with it.”
    + Read it on dodopayments.com

Why the usual fixes close only one gap

@@ -1367,7 +1472,7 @@

Not a contrived example. Other teams hit these bugs in 2026:

retry schedule · 0s → 27.6h

Watch it retry.

-

One invoice.paid, three of your customers' servers. Each endpoint retries on its own schedule for up to 28 hours, here squeezed into a few seconds.

+

One invoice.paid, three of your customers’ servers. Each endpoint retries on its own schedule for up to 28 hours, here squeezed into a few seconds.

@@ -1449,7 +1554,7 @@

Your customers verify it with code they already have.

-
+

hookgres.deliveries

Every attempt is a row you can query.

@@ -1581,7 +1686,6 @@

Your next webhook ships in your next commit.

- From 73ad44d6ef7020dc6f01296787aa98bf3b843e4d Mon Sep 17 00:00:00 2001 From: Nikos Benakis Date: Fri, 13 Nov 2026 20:56:20 +0200 Subject: [PATCH 15/15] Landing page: demos keep playing under the mouse Hover no longer pauses the live demos; only the reader's own click or keyboard focus holds one, for eight seconds. Co-Authored-By: Claude Opus 5.5 --- site/index.html | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/site/index.html b/site/index.html index da701ce..6162561 100644 --- a/site/index.html +++ b/site/index.html @@ -1967,19 +1967,18 @@

Your next webhook ships in your next commit.

})(); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - // Demos play themselves until the reader reaches for one (hover, press or - // keyboard focus), then hand over exactly where they are. Eight seconds after - // the reader leaves, they carry on from that state. + // Demos play themselves, hover or not. A reader's own click (or keyboard + // focus) hands over exactly where they are; eight seconds later they carry + // on from that state. const QUIET = 8000; function pilot(root, { onTake, onResume } = {}) { let live = false; - let hover = false; let focus = false; let timer = 0; const waiting = []; const update = () => { clearTimeout(timer); - if (hover || focus) { + if (focus) { if (live) return; live = true; root.dataset.live = ""; @@ -2004,16 +2003,6 @@

Your next webhook ships in your next commit.

} update(); }; - root.addEventListener("pointerenter", (event) => { - if (event.pointerType === "touch") return; - hover = true; - update(); - }); - root.addEventListener("pointerleave", (event) => { - if (event.pointerType === "touch") return; - hover = false; - update(); - }); // The cursor's own clicks are untrusted. A finger scrolling past fires no click. root.addEventListener("click", (event) => { if (event.isTrusted) poke(); @@ -2410,7 +2399,7 @@

Your next webhook ships in your next commit.

} // Verify in any language: the tabs step through the languages while on screen. A - // reader holds the current one (hover, tap or keyboard); it moves on after they leave. + // reader holds the current one (a click or keyboard focus); it moves on eight seconds later. { const verify = $(".verify"); const panels = [...verify.querySelectorAll(".verify-panel")];
QuestionHookgresHosted service