diff --git a/site/index.html b/site/index.html index 56fee67..6162561 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,18 +282,25 @@ 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); } + /* 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; } + /* The agent row shows the promise; its copy button holds the actual prompt. */ + .start-cmd.start-pitch-line { font: 600 15px/1.5 var(--sans); letter-spacing: -0.01em; } + .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; @@ -324,7 +331,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 +349,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,15 +362,56 @@ .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); } } .tab-panel[hidden] { display: none; } /* Both files keep the taller one's height, so switching tabs doesn't shift the page. */ .hero-code pre { font-size: 12.5px; line-height: 1.65; padding: 16px 20px 20px; min-height: calc(22lh + 36px); } + /* 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: 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); } } /* What's in the box: six capabilities, one line each. */ .caps { @@ -412,15 +460,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; + font-size: clamp(28px, 0.9rem + 1.6vw, 38px); + line-height: 1.12; letter-spacing: -0.028em; font-weight: 700; + margin: 0; max-width: 18em; text-wrap: balance; } - .kicker { margin: 0 0 10px; font-size: 14px; font-weight: 500; color: var(--accent); } - .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; } + /* 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 16px; + font: 500 15px/1.3 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: 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); } @@ -430,42 +484,53 @@ .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); } .points b { color: var(--ink); font-weight: 600; } .points code { color: var(--ink); } - /* Windows (delivery log, simulator) */ + /* Windows (delivery log, retry race) */ .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); } + @media (max-width: 560px) { .window-bar { padding: 12px 14px; } } .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; } - @media (max-width: 560px) { .window-bar .meta.log-id { display: none; } .log { padding: 4px 14px 10px; } .window-bar { padding: 12px 14px; } } - .log-row { - display: grid; - grid-template-columns: minmax(0, 190px) repeat(8, minmax(0, 1fr)) minmax(0, 110px); - align-items: center; gap: 8px; - padding: 11px 0; border-top: 1px solid var(--line); - } - .log-row.head { border-top: 0; padding: 6px 0 8px; font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; } - .log-row.head span { text-align: center; } - .log-row.head span:first-child, .log-row.head span:last-child { text-align: left; } - .endpoint { min-width: 0; } - .endpoint b { display: block; font-weight: 500; font-size: 14px; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .endpoint small { display: block; font-size: 12px; color: var(--ink-3); } - .slot { display: flex; justify-content: center; } .attempt { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 7px; @@ -477,134 +542,228 @@ .attempt.ok { color: var(--ok); } .attempt.warn { color: var(--warn); } .attempt.bad { color: var(--bad); } - .status { font-size: 13px; font-weight: 500; } - .status.ok { color: var(--ok); } - .status.bad { color: var(--bad); } - .status small { display: block; font-weight: 400; color: var(--ink-3); font-size: 12px; } - @media (max-width: 760px) { - .log-row { grid-template-columns: minmax(0, 1fr) auto; row-gap: 10px; } - .log-row.head { display: none; } - .log-row .slots { grid-column: 1 / -1; grid-row: 2; display: flex; flex-wrap: wrap; gap: 6px; } - .log-row .status { grid-column: 2; grid-row: 1; text-align: right; } - .slot:empty { display: none; } - } - @media (min-width: 761px) { .log-row .slots { display: contents; } } - - /* Segmented control (simulator) */ - .seg { - position: relative; display: inline-grid; grid-auto-flow: column; grid-auto-columns: 1fr; - padding: 3px; border-radius: 999px; background: var(--bg-alt); box-shadow: inset 0 0 0 1px var(--line); - --i: 0; --n: 3; - } - .seg button { - position: relative; z-index: 1; border: 0; background: none; cursor: pointer; - font: 500 13.5px/1 var(--sans); letter-spacing: -0.01em; color: var(--ink-2); - padding: 10px 16px; border-radius: 999px; white-space: nowrap; - transition: color 200ms var(--ease); - } - .seg button::before { content: ""; position: absolute; inset: -6px 0; } - .seg button[aria-pressed="true"] { color: var(--ink); } - .seg-thumb { - position: absolute; top: 3px; bottom: 3px; left: 3px; - width: calc((100% - 6px) / var(--n)); border-radius: 999px; - background: var(--surface); box-shadow: 0 0 0 1px oklch(25% 0.05 var(--h) / 0.04), 0 1px 2px oklch(25% 0.05 var(--h) / 0.08), 0 2px 8px oklch(25% 0.05 var(--h) / 0.06); - transform: translateX(calc(var(--i) * 100%)); - transition: transform 280ms var(--ease-out); - } - fieldset.seg { margin: 0; border: 0; min-inline-size: 0; } - @media (max-width: 400px) { .seg button { padding: 10px 10px; } } + .live-toggle { + position: relative; display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px 0 9px; + border: 0; border-radius: 999px; cursor: pointer; font: 500 12px/1 var(--sans); letter-spacing: 0; + color: var(--ink-2); background: color-mix(in oklch, var(--ink) 7%, transparent); + transition: background-color 150ms var(--ease), color 150ms var(--ease), transform 150ms var(--ease); + } + .live-toggle[hidden] { display: none; } + .live-toggle::before { content: ""; position: absolute; inset: -9px -2px; } + .live-toggle:active { transform: scale(0.96); } + @media (hover: hover) and (pointer: fine) { .live-toggle:hover { color: var(--ink); background: color-mix(in oklch, var(--ink) 11%, transparent); } } + .live-dot { position: relative; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); } + .live-dot::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--ok); animation: live-ping 1.8s var(--ease-out) infinite; } + .live-toggle[data-paused] .live-dot { background: var(--ink-3); } + .live-toggle[data-paused] .live-dot::after { animation: none; opacity: 0; } + @keyframes live-ping { 0% { transform: scale(1); opacity: 0.6; } 80%, 100% { transform: scale(2.6); opacity: 0; } } + /* Interactive demos */ .demo { min-width: 0; } - .pill.action { - height: 40px; padding: 0 18px; font-size: 15px; - background: var(--accent); color: var(--accent-ink); + .tx-demo { position: relative; } + /* Self-playing demos: a cursor works the real controls until the reader reaches + for them. Mouse only; on touch the demo still plays, just without a cursor. */ + .demo-cursor { + position: absolute; left: 0; top: 0; z-index: 1; width: 20px; height: 20px; + pointer-events: none; user-select: none; opacity: 0; + transform: translate(var(--gx, 0), var(--gy, 0)); + transition: transform 620ms cubic-bezier(0.45, 0, 0.2, 1), opacity 180ms var(--ease-out); + filter: drop-shadow(0 1px 1.5px oklch(0% 0 0 / 0.35)); } - @media (hover: hover) and (pointer: fine) { .pill.action:hover { background: var(--accent-hover); } } - .pill.action.quiet { background: var(--surface); color: var(--ink); box-shadow: var(--raised); } - @media (hover: hover) and (pointer: fine) { .pill.action.quiet:hover { background: color-mix(in oklch, var(--ink) 4%, var(--surface)); } } - - .bar-hint { color: var(--code-dim); } + .demo-cursor[data-on] { opacity: 1; } + .demo-cursor-arrow { display: block; width: 100%; height: 100%; transform-origin: 4px 3px; transition: scale 120ms var(--ease-out); } + .demo-cursor[data-down] .demo-cursor-arrow { scale: 0.86; } + [data-cursor-down] { transform: scale(0.96); } + @media not ((hover: hover) and (pointer: fine)) { .demo-cursor { display: none; } } .tx-demo pre { white-space: pre-wrap; word-break: break-word; } .ln { display: block; padding: 0 6px; margin: 0 -6px; border-radius: 6px; transition: background-color 240ms var(--ease), opacity 240ms var(--ease); } .tx-demo[data-state="commit"] .tx-end { background: oklch(76% 0.15 152 / 0.16); } .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; } - .flow { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; margin: 20px 4px 0; } - .node b { display: block; font-size: 14px; font-weight: 600; letter-spacing: -0.01em; } - .node small { display: block; font-size: 12.5px; color: var(--ink-3); transition: color 200ms var(--ease); } - .node:last-child { text-align: right; } - .track { position: relative; height: 26px; } - .track::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1.5px dashed var(--line); } - .packet { - position: absolute; left: 0; top: 0; height: 26px; display: inline-flex; align-items: center; - padding: 0 10px; border-radius: 8px; font: 500 12px/1 var(--mono); letter-spacing: 0; - background: var(--accent); color: var(--accent-ink); - opacity: 0; transform: translateX(0) scale(0.95); - } - .packet.go { animation: travel 900ms cubic-bezier(0.65, 0, 0.35, 1) forwards; } - .packet.drop { animation: drop 420ms var(--ease-out) forwards; } - @keyframes travel { - 0% { opacity: 0; transform: translateX(0) scale(0.95); } - 15% { opacity: 1; transform: translateX(0) scale(1); } - 85% { opacity: 1; transform: translateX(var(--dist)) scale(1); } - 100% { opacity: 0; transform: translateX(var(--dist)) scale(0.95); } - } - @keyframes drop { - 0% { opacity: 1; transform: translateX(0) scale(1); } - 100% { opacity: 0; transform: translateX(0) translateY(10px) scale(0.92); } - } - .tx-rx.ok { color: var(--ok); } - .tx-db.bad { color: var(--bad); } - .demo-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 20px; } - .demo-status { margin: 0 0 0 6px; font-size: 14px; color: var(--ink-2); flex: 1 1 200px; } + .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. */ + .tx-card { margin-top: 16px; } + .tx-stage { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(44px, 0.55fr) minmax(0, 1fr); align-items: stretch; gap: 0; padding: 18px; } + .tx-box { min-width: 0; border-radius: 12px; padding: 12px; background: color-mix(in srgb, var(--ink) 2.5%, var(--surface)); box-shadow: inset 0 0 0 1px var(--line); } + .tx-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; } + .tx-name { font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; color: var(--ink); } + .tx-badge { + font: 500 11px/1 var(--mono); letter-spacing: 0.02em; padding: 5px 7px; border-radius: 6px; + color: var(--ink-3); background: color-mix(in oklch, var(--ink) 6%, transparent); + transition: color 200ms var(--ease), background-color 200ms var(--ease); + } + .tx-badge[data-s="ok"] { color: var(--ok); background: color-mix(in oklch, var(--ok) 13%, transparent); } + .tx-badge[data-s="bad"] { color: var(--bad); background: color-mix(in oklch, var(--bad) 13%, transparent); } + .tx-rows { display: grid; gap: 6px; } + .tx-row { + position: relative; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; border-radius: 8px; + font: 12px/1 var(--mono); letter-spacing: 0; color: var(--ink-2); background: var(--surface); + box-shadow: inset 0 0 0 1px var(--line); + opacity: 0; transform: translateY(6px); + transition: opacity 260ms var(--ease-out), transform 360ms var(--ease-out), box-shadow 220ms var(--ease), background-color 220ms var(--ease), color 220ms var(--ease); + } + .tx-row .tx-tbl { color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } + .tx-row .tx-val { margin-left: auto; white-space: nowrap; color: var(--ink); } + .tx-row .tx-tick { flex: none; width: 14px; height: 14px; margin-left: -2px; color: var(--ok); opacity: 0; transform: scale(0.6); transition: opacity 200ms var(--ease), transform 300ms var(--ease-out); } + .tx-row[data-s] { opacity: 1; transform: none; } + .tx-row[data-s="pending"] { box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 5%, var(--surface)); } + .tx-row[data-s="ok"] { box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ok) 40%, transparent); background: color-mix(in srgb, var(--ok) 7%, var(--surface)); } + .tx-row[data-s="ok"] .tx-tick { opacity: 1; transform: none; } + .tx-row[data-s="bad"] { opacity: 0.45; box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--bad) 40%, transparent); background: color-mix(in srgb, var(--bad) 6%, var(--surface)); } + .tx-row[data-s="bad"] .tx-val, .tx-row[data-s="bad"] .tx-tbl { text-decoration: line-through; text-decoration-color: var(--bad); } + .tx-wire { position: relative; align-self: center; height: 34px; margin: 0 10px; } + .tx-wire-rail, .tx-wire-fill { position: absolute; left: 0; right: 0; top: 50%; height: 2px; margin-top: -1px; border-radius: 1px; } + .tx-wire-rail { background: repeating-linear-gradient(90deg, var(--line) 0 4px, transparent 4px 9px); } + .tx-wire-fill { background: var(--ok); transform-origin: left; transform: scaleX(0); } + .tx-wire-head { position: absolute; inset: 0; transform: translateX(0); pointer-events: none; } + .tx-wire-head::after { + content: ""; position: absolute; left: -6px; top: 50%; width: 12px; height: 12px; margin-top: -6px; border-radius: 50%; + background: var(--accent); box-shadow: 0 0 0 5px color-mix(in oklch, var(--accent) 20%, transparent), 0 0 16px color-mix(in oklch, var(--accent) 55%, transparent); + opacity: 0; transition: opacity 160ms var(--ease); + } + .tx-wire[data-s="go"] .tx-wire-fill, .tx-wire[data-s="done"] .tx-wire-fill { transform: scaleX(1); transition: transform 760ms cubic-bezier(0.65, 0, 0.35, 1); } + .tx-wire[data-s="go"] .tx-wire-head { transform: translateX(100%); transition: transform 760ms cubic-bezier(0.65, 0, 0.35, 1); } + .tx-wire[data-s="go"] .tx-wire-head::after { opacity: 1; } + .tx-wire-x { + position: absolute; left: 50%; top: 50%; width: 22px; height: 22px; margin: -11px 0 0 -11px; border-radius: 50%; + display: grid; place-items: center; color: var(--bad); background: var(--surface); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--bad) 40%, transparent); + opacity: 0; transform: scale(0.6); transition: opacity 200ms var(--ease), transform 300ms var(--ease-out); + } + .tx-wire-x svg { width: 10px; height: 10px; } + .tx-wire[data-s="cut"] .tx-wire-x { opacity: 1; transform: none; } + .tx-ep { position: relative; } + .tx-code { display: flex; align-items: center; gap: 10px; height: 34px; font: 600 24px/1 var(--mono); letter-spacing: -0.02em; color: var(--ink-3); font-variant-numeric: tabular-nums; transition: color 200ms var(--ease); } + .tx-pulse { position: relative; flex: none; width: 10px; height: 10px; border-radius: 50%; background: color-mix(in oklch, var(--ink) 18%, transparent); transition: background-color 200ms var(--ease); } + .tx-note { margin: 6px 0 0; min-height: 2lh; font-size: 12.5px; line-height: 1.35; color: var(--ink-3); } + .tx-ep[data-s="ok"] .tx-code { color: var(--ok); } + .tx-ep[data-s="ok"] .tx-pulse { background: var(--ok); } + .tx-ep[data-s="ok"] .tx-pulse::after { content: ""; position: absolute; inset: 0; border-radius: 50%; background: var(--ok); animation: mark-ring 800ms var(--ease-out) both; } + .tx-ep[data-s="none"] .tx-code { color: var(--ink-3); } + .tx-status { margin: 14px 2px 0; min-height: 2lh; font-size: 14px; line-height: 1.5; color: var(--ink-2); text-wrap: pretty; } + @media (max-width: 560px) { + .tx-stage { grid-template-columns: minmax(0, 1fr); padding: 14px; } + .tx-wire { height: 40px; width: 2px; margin: 0 auto; } + .tx-wire-rail, .tx-wire-fill { left: 0; right: auto; top: 0; bottom: 0; width: 2px; height: auto; margin: 0; } + .tx-wire-rail { background: repeating-linear-gradient(180deg, var(--line) 0 4px, transparent 4px 9px); } + .tx-wire-fill { transform-origin: top; transform: scaleY(0); } + .tx-wire-head::after { left: 50%; top: 0; margin: -6px 0 0 -6px; } + .tx-wire[data-s="go"] .tx-wire-fill, .tx-wire[data-s="done"] .tx-wire-fill { transform: scaleY(1); } + .tx-wire[data-s="go"] .tx-wire-head { transform: translateY(100%); } + .tx-note { min-height: 1lh; } + } .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-window .live-toggle { margin-left: 4px; } + + /* Retry race: one event, three endpoints, attempts evenly spaced along the schedule. */ + .race { padding: 4px 18px 0; touch-action: pan-y; } + .race-row { display: grid; grid-template-columns: minmax(0, 190px) minmax(0, 1fr) minmax(0, 150px); align-items: center; gap: 16px; } + .lane { border-top: 1px solid var(--line); } + .lane:first-child { border-top: 0; } + .lane-track { position: relative; height: 64px; cursor: ew-resize; } + .lane-inner { position: absolute; inset: 0 16px; } + .lane-rail, .lane-fill { position: absolute; left: 0; right: 0; top: 42px; height: 2px; margin-top: -1px; border-radius: 1px; } + .lane-rail { background: repeating-linear-gradient(90deg, var(--line) 0 4px, transparent 4px 9px); } + .lane-fill { background: var(--tone, var(--accent)); transform-origin: left; transform: scaleX(var(--p, 0)); transition: background-color 300ms var(--ease); } + .lane[data-result="ok"] { --tone: var(--ok); } + .lane[data-result="bad"] { --tone: var(--bad); } + .lane-head { position: absolute; inset: 0; transform: translateX(calc(var(--p, 0) * 100%)); pointer-events: none; } + .lane-head::after { + content: ""; position: absolute; left: -6px; top: 36px; width: 12px; height: 12px; border-radius: 50%; + background: var(--accent); box-shadow: 0 0 0 5px color-mix(in oklch, var(--accent) 20%, transparent), 0 0 16px color-mix(in oklch, var(--accent) 55%, transparent); + opacity: 0; transition: opacity 200ms var(--ease); + } + .lane[data-active] .lane-head::after { opacity: 1; } + .mark { position: absolute; left: calc(var(--x) * 100%); top: 42px; transition: opacity 400ms var(--ease); } + .mark[data-skip] { opacity: 0.3; } + .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), box-shadow 200ms var(--ease); + } + .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] .mark-dot::after { + content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--tone); + animation: mark-ring 700ms var(--ease-out) both; + } + @keyframes mark-ring { from { transform: scale(1); opacity: 0.55; } to { transform: scale(3.2); opacity: 0; } } + .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) 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); } } - .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; } - .sim-status { padding: 14px 16px; } + .endpoint { min-width: 0; } + .endpoint b { display: block; font-weight: 500; font-size: 14px; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .endpoint small { display: block; font-size: 12px; color: var(--ink-3); } + .status { font-size: 13px; font-weight: 500; transition: color 200ms var(--ease); } + .status.ok { color: var(--ok); } + .status.bad { color: var(--bad); } + .status.live { color: var(--warn); } + .status small { display: block; font-weight: 400; color: var(--ink-3); font-size: 12px; } + .lane .status { font-variant-numeric: tabular-nums; } + .lane .status small { white-space: nowrap; } + .race-axis { padding: 2px 0 12px; } + .race-axis .lane-track { height: 18px; } + .race-axis .tick { + position: absolute; left: calc(var(--x) * 100%); top: 2px; transform: translateX(-50%); + font: 500 11px/1 var(--mono); letter-spacing: 0; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; + } + .race-lanes { transition: opacity 300ms var(--ease); } + .race-lanes[data-fading] { opacity: 0; } + .sim-note { margin: 0; padding: 14px 24px 16px; border-top: 1px solid var(--line); font-size: 14.5px; color: var(--ink-2); } + @media (max-width: 760px) { + .race { padding: 4px 14px 0; } + .race-row { grid-template-columns: minmax(0, 1fr) auto; row-gap: 0; column-gap: 12px; } + .lane { padding-top: 12px; } + .lane .lane-track { grid-column: 1 / -1; grid-row: 2; height: 40px; } + .lane .status { grid-column: 2; grid-row: 1; text-align: right; min-width: 9.5em; } + .race-axis .race-label, .race-axis .race-end { display: none; } + .race-axis .lane-track { grid-column: 1 / -1; } + .lane-inner { inset: 0 10px; } + /* Too narrow for eight chips side by side: the dots carry the colour. */ + .mark-chip { display: none; } + .lane-rail, .lane-fill, .mark { top: 22px; } + .lane-head::after { top: 16px; } + .race-axis .tick { font-size: 10px; } + .sim-note { padding: 14px 16px; } } - .sig-label { display: block; font-size: 14px; color: var(--ink-2); margin: 0 0 8px; } - .sig textarea { - width: 100%; min-height: 64px; resize: vertical; margin: 0 0 14px; - font: 16px/1.5 var(--mono); letter-spacing: 0; color: var(--ink); - background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; - transition: border-color 150ms var(--ease); + .verify { display: grid; gap: 14px; } + .verify-req pre { white-space: pre-wrap; word-break: break-all; font-size: 12.5px; color: var(--code-dim); } + .verify-req .k { color: var(--code-key); } + .verify-code .tabs { flex-wrap: wrap; } + .tab-time { + position: absolute; left: 10px; right: 10px; bottom: -7px; height: 2px; border-radius: 1px; + background: var(--code-key); transform: scaleX(0); transform-origin: left; opacity: 0; + } + .verify[data-auto] .tab[aria-selected="true"] .tab-time { opacity: 1; animation: tab-time 3.6s linear forwards; } + .verify[data-auto]:not([data-onscreen]) .tab[aria-selected="true"] .tab-time, + .verify[data-auto][data-live] .tab[aria-selected="true"] .tab-time { animation-play-state: paused; } + @keyframes tab-time { to { transform: scaleX(1); } } + /* Every panel shares one grid cell, so the card keeps the tallest one's height. */ + .verify-panels { display: grid; } + .verify-panel { + grid-area: 1 / 1; white-space: pre-wrap; word-break: break-word; + opacity: 0; visibility: hidden; transform: translateY(4px); + transition: opacity 180ms var(--ease), transform 180ms var(--ease), visibility 0s linear 180ms; + } + .verify-panel[data-active] { + opacity: 1; visibility: visible; transform: none; + transition: opacity 260ms var(--ease-out) 60ms, transform 260ms var(--ease-out) 60ms, visibility 0s; + } + /* Phones: code keeps its lines and scrolls sideways instead of breaking mid-token. */ + @media (max-width: 640px) { + .verify-req pre, .verify-panel { white-space: pre; word-break: normal; } } - .sig textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 18%, transparent); } - .sig pre { white-space: pre-wrap; word-break: break-all; } - .sig-value { transition: opacity 150ms var(--ease); } - .sig-value.fresh { animation: flash 420ms var(--ease); } - @keyframes flash { from { opacity: 0.35; } } - .sig-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; } - .switch { display: inline-flex; align-items: center; gap: 10px; font-size: 14.5px; color: var(--ink-2); cursor: pointer; -webkit-user-select: none; user-select: none; } - .switch input { position: absolute; opacity: 0; width: 1px; height: 1px; } - .switch-ui { position: relative; width: 40px; height: 24px; border-radius: 999px; background: var(--line); transition: background-color 200ms var(--ease); flex: none; } - .switch-ui::after { content: ""; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: oklch(100% 0 0); box-shadow: 0 1px 3px oklch(25% 0.05 var(--h) / 0.2); transition: transform 240ms var(--ease-out); } - .switch input:checked + .switch-ui { background: var(--bad); } - .switch input:checked + .switch-ui::after { transform: translateX(16px); } - .switch input:focus-visible + .switch-ui { outline: 2px solid var(--accent); outline-offset: 3px; } - .verdict { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 14px; border-radius: 999px; font-size: 14px; font-weight: 500; background: color-mix(in oklch, currentColor 11%, transparent); color: var(--ink-2); transition: color 200ms var(--ease), background-color 200ms var(--ease); } - .verdict::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } - .verdict.ok { color: var(--ok); } - .verdict.bad { color: var(--bad); } - .sig-secret { margin: 14px 0 0; font-size: 13px; color: var(--ink-3); word-break: break-all; } .langs { margin: 20px 0 0; color: var(--ink-3); font-size: 14.5px; } .langs b { color: var(--ink-2); font-weight: 500; } @@ -621,19 +780,82 @@ .number:nth-child(n + 3) { margin-top: 22px; border-top: 1px solid var(--line); } } @media (max-width: 460px) { .number, .number + .number { padding-right: 12px; } .number + .number:nth-child(even) { padding-left: 14px; } } + /* Each default gets a small drawing of itself. */ + .mini { display: block; width: 120px; height: 32px; margin-bottom: 16px; overflow: visible; } + .mini-retry line { stroke: var(--line); stroke-width: 1.5; } + .mini-retry circle { fill: var(--accent); } + .mini-days rect { fill: var(--bad); } + .mini-rotate .old { fill: color-mix(in oklch, var(--ink) 30%, transparent); } + .mini-rotate .new { fill: var(--accent); } + .mini-rotate .overlap { fill: color-mix(in oklch, var(--ok) 14%, transparent); stroke: var(--ok); stroke-width: 1; stroke-dasharray: 3 3; } + .mini-grid rect { fill: var(--ok); opacity: 0.85; } .note { margin: 22px 0 0; font-size: 14px; color: var(--ink-3); } .note code { color: var(--ink-2); } .log-stage { margin-top: 36px; } - .log-foot { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(32px, 5vw, 64px); margin-top: 40px; align-items: start; } - @media (max-width: 900px) { .log-foot { grid-template-columns: minmax(0, 1fr); } } - .log-foot .points { margin-top: 0; } + /* SQL console: real queries against Hookgres's tables, run by picking a tab. */ + .console .bar { padding-left: 8px; } + .console .tabs { margin-left: 0; min-width: 0; } + .console .tab { white-space: nowrap; } + .console-panels { display: grid; } + .console-panel { grid-area: 1 / 1; min-width: 0; visibility: hidden; opacity: 0; transition: opacity 160ms var(--ease), visibility 0s linear 160ms; } + .console-panel[data-active] { visibility: visible; opacity: 1; transition: opacity 200ms var(--ease-out), visibility 0s; } + .console .console-sql { padding: 18px 20px 16px; border-bottom: 1px solid var(--code-line); white-space: pre; font-size: 12.5px; } + .console-result { overflow-x: auto; padding: 4px 20px 0; } + .console table { width: 100%; border-collapse: collapse; font: 12.5px/1.2 var(--mono); letter-spacing: 0; color: var(--code-ink); font-variant-numeric: tabular-nums; } + .console th, .console td { height: 44px; padding: 0 20px 0 0; text-align: left; white-space: nowrap; border-bottom: 1px solid color-mix(in oklch, var(--code-line) 70%, transparent); } + .console th { height: 38px; font-weight: 500; color: var(--code-dim); } + .console th:last-child, .console td:last-child { padding-right: 0; } + .console .num { text-align: right; } + .console td.err { color: var(--bad); } + .console td.act { width: 1%; text-align: right; } + .console-panel[data-active] tbody > * { animation: row-in 300ms var(--ease-out) both; animation-delay: calc(var(--r) * 45ms); } + @keyframes row-in { from { opacity: 0; transform: translateY(4px); } } + .console-panel[data-active] tbody > [data-flash] { animation: row-flash 1400ms var(--ease) both; } + @keyframes row-flash { from { background: color-mix(in oklch, var(--ok) 16%, transparent); } } + .st { display: inline-flex; align-items: center; gap: 6px; } + .st::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } + .st-ok { color: var(--ok); } + .st-bad { color: var(--bad); } + .st-wait { color: var(--warn); } + .replay { + position: relative; min-width: 86px; height: 28px; padding: 0 11px; border: 0; border-radius: 7px; cursor: pointer; + font: 500 12.5px/1 var(--sans); letter-spacing: 0; color: var(--code-ink); background: oklch(100% 0 0 / 0.1); + transition: background-color 150ms var(--ease), color 150ms var(--ease), transform 150ms var(--ease); + } + .replay::before { content: ""; position: absolute; inset: -8px -4px; } + .replay:active { transform: scale(0.96); } + .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); } + .console-foot code { color: var(--code-key); font-size: 1em; } + @media (max-width: 640px) { + .console .console-sql { white-space: pre-wrap; word-break: break-word; padding: 16px 16px 14px; font-size: 12px; } + .console-result { padding: 2px 16px 0; } + .console table { font-size: 12px; } + .console th, .console td { padding-right: 12px; } + .console .opt { display: none; } + .console-foot { padding: 12px 16px 14px; } + } + .log-points { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: clamp(32px, 5vw, 64px); row-gap: 16px; margin-top: 40px; } + @media (max-width: 760px) { .log-points { grid-template-columns: minmax(0, 1fr); } } /* 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), 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(--ink); outline-offset: 2px; } + .video-pick ~ .demo-video-stage { margin-top: 16px; } + .demo-video-stage[hidden] { display: none; } + .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); } @@ -643,6 +865,10 @@ .table thead th { font-size: 13px; color: var(--ink-3); font-weight: 500; padding-block: 12px; border-top: 1px solid var(--line); } .table tbody th { color: var(--ink-2); font-weight: 400; width: 36%; } .table td.us { color: var(--ink); font-weight: 500; } + /* The Hookgres column reads as one lit strip down the table. */ + .table .us { padding-left: 18px; background: color-mix(in oklch, var(--accent) 6%, transparent); } + .table thead .us { color: var(--accent); } + .table .us + td, .table .us + th { padding-left: 24px; } @media (max-width: 640px) { .table { font-size: 14px; } .table th, .table td { padding-right: 12px; } } .waitlist { margin-top: 48px; padding-top: 32px; border-top: 1px solid var(--line); max-width: 40em; scroll-margin-top: 72px; } @@ -656,7 +882,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); } @@ -664,17 +890,191 @@ /* Closing */ .cta h2 { font-size: clamp(32px, 1rem + 2.6vw, 48px); } .cta .actions { margin-top: 28px; } + /* The close is a panel of the night band, so the page ends where the engine lives. */ + .cta-panel { padding: clamp(32px, 5.5vw, 72px); border-radius: 28px; overflow: hidden; box-shadow: 0 40px 80px -40px oklch(25% 0.08 var(--h) / 0.45); } + .cta-panel.night::before { -webkit-mask-image: radial-gradient(ellipse 70% 90% at 78% 50%, #000, transparent 75%); mask-image: radial-gradient(ellipse 70% 90% at 78% 50%, #000, transparent 75%); } + @media (max-width: 640px) { .cta-panel { border-radius: 20px; margin-inline: -4px; } } footer { border-top: 1px solid var(--line); padding: 28px 0 calc(40px + env(safe-area-inset-bottom)); font-size: 13px; color: var(--ink-3); } .footer-inner { display: flex; flex-wrap: wrap; gap: 14px 28px; justify-content: space-between; } .footer-links { display: flex; flex-wrap: wrap; gap: 8px 22px; } footer .footer-inner a { color: var(--ink-2); } + /* Night band: the three sections about what happens inside Postgres (retries, + signatures, the log) sit in deep Postgres blue. It re-declares the tokens, so every + component inside renders its dark variant without its own rules. */ + :root { --page-bg: var(--bg); --night: oklch(19% 0.05 var(--h)); } + :root[data-theme="dark"] { --night: oklch(15% 0.035 var(--h)); } + .night { + color-scheme: dark; + --bg: var(--night); + --bg-alt: oklch(23% 0.05 var(--h)); + --surface: oklch(23.5% 0.05 var(--h)); + --ink: oklch(96.5% 0.006 var(--h)); + --ink-2: oklch(78% 0.025 var(--h)); + --ink-3: oklch(70% 0.025 var(--h)); + --line: oklch(33% 0.045 var(--h)); + --accent: oklch(75% 0.13 var(--h)); + --accent-hover: oklch(81% 0.11 var(--h)); + --accent-ink: oklch(18% 0.05 var(--h)); + --ok: oklch(78% 0.15 152); + --warn: oklch(82% 0.13 75); + --bad: oklch(72% 0.16 27); + --code-bg: oklch(15% 0.04 var(--h)); + --code-line: oklch(28% 0.04 var(--h)); + --shadow: 0 0 0 1px oklch(100% 0 0 / 0.08), 0 24px 60px -24px oklch(5% 0.05 var(--h) / 0.7); + --sheen: inset 0 1px 0 oklch(100% 0 0 / 0.08); + --raised: var(--sheen), 0 0 0 1px oklch(100% 0 0 / 0.1); + --raised-hover: var(--sheen), 0 0 0 1px oklch(100% 0 0 / 0.18); + --material: linear-gradient(oklch(27% 0.05 var(--h)), oklch(24% 0.05 var(--h))); + --button-sheen: inset 0 1px 0 oklch(100% 0 0 / 0.3); + position: relative; isolation: isolate; + background: var(--night); color: var(--ink); + } + /* The same dot grid as the hero, faint, so the band reads as one surface. */ + .night::before { + content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; + background: radial-gradient(circle at 1px 1px, oklch(100% 0 0 / 0.07) 1px, transparent 1.4px) 0 0 / 22px 22px; + -webkit-mask-image: linear-gradient(transparent, #000 220px, #000 calc(100% - 220px), transparent); + 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. */ + /* 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); } + section.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(232px, 18vw, 272px); + } + section.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(232px, 18vw, 272px); + } + section.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. + 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. 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-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; + } + /* 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)); + 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); } + + /* 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] { 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: 5 6; } + .sk-streak { fill: none; stroke-width: 2.5; stroke-linecap: round; } + .sk-head-halo { opacity: 0.22; } + .sk-glow-frame { fill: color-mix(in oklch, var(--accent) 7%, transparent); stroke-dasharray: 5 5; } + /* Focus a step: everything outside it steps back. */ + .sketch [data-step] { transition: opacity 260ms var(--ease); } + .sketch[data-focus] [data-step] { opacity: 0.22; } + .sketch[data-focus="1"] [data-step~="1"], .sketch[data-focus="2"] [data-step~="2"], + .sketch[data-focus="3"] [data-step~="3"], .sketch[data-focus="4"] [data-step~="4"] { opacity: 1; } + .steps { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--line); } + .step { + position: relative; display: grid; align-content: start; gap: 6px; min-width: 0; + padding: 18px 22px 22px; border: 0; background: none; cursor: pointer; text-align: left; + font: inherit; color: inherit; transition: background-color 150ms var(--ease); + } + .step + .step { box-shadow: inset 1px 0 0 var(--line); } + .step-tag { font: 500 12.5px/1.2 var(--mono); letter-spacing: 0; color: var(--ink-2); transition: color 200ms var(--ease); } + .step-text { font-size: 13.5px; line-height: 1.45; color: var(--ink-3); text-wrap: pretty; transition: color 200ms var(--ease); } + .step-bar { + position: absolute; left: 0; right: 0; top: -1px; height: 2px; background: var(--accent); + transform: scaleX(var(--p, 0)); transform-origin: left; opacity: 0; transition: opacity 200ms var(--ease); + } + .step[data-active] .step-bar { opacity: 1; } + .step[data-active] .step-tag, .step[aria-pressed="true"] .step-tag { color: var(--accent); } + .step[data-active] .step-text, .step[aria-pressed="true"] .step-text { color: var(--ink-2); } + .step[aria-pressed="true"] { background: color-mix(in oklch, var(--accent) 5%, transparent); } + .step:focus-visible { outline-offset: -2px; } + @media (hover: hover) and (pointer: fine) { .step:hover { background: color-mix(in oklch, var(--ink) 3%, transparent); } } + @media (max-width: 760px) { + .steps { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .step:nth-child(3) { box-shadow: inset 0 1px 0 var(--line); } + .step:nth-child(4) { box-shadow: inset 1px 0 0 var(--line), inset 0 1px 0 var(--line); } + .step { padding: 16px 16px 18px; } + } + @media (max-width: 640px) { + .flow-sketch { margin-top: 24px; border-radius: 16px; } + } + @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; } + .hero-code, .hero-shadow { transform: none; } + .mark-dot, .mark, .lane-fill, .race-lanes, [data-spot]::after, .sketch [data-step], .step-bar { transition: none; } + .mark-chip .attempt, .mark[data-state] .mark-dot::after { animation: none; } + } + @media (prefers-reduced-motion: reduce) { + .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; } - .packet.go, .packet.drop, .sim-slot .attempt, .sig-value.fresh { animation: none; } - .packet.go { opacity: 0; } + .live-dot::after, .tx-ep[data-s="ok"] .tx-pulse::after { animation: none; } + .tx-row, .tx-row .tx-tick, .tx-badge, .tx-wire-x, .tx-wire-head::after, .tx-wire[data-s] .tx-wire-fill, .tx-wire[data-s] .tx-wire-head { transition: none; } } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } @@ -711,6 +1111,7 @@
+
@@ -718,15 +1119,15 @@

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

- -

Open inOpen in Claude CodeOpen in CodexOpen in Cursor

+

Open inOpen in Claude CodeOpen in CodexOpen in Cursor

@@ -737,7 +1138,7 @@

Webhooks that commit with your data.

npm

npm install hookgres pg

- @@ -750,13 +1151,15 @@

Webhooks that commit with your data.

-
+
+ +
- @@ -800,6 +1203,7 @@

Webhooks that commit with your data.

} }
+
    @@ -813,19 +1217,175 @@

    Webhooks that commit with your data.

+
+
+

when you need it

+

“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.

+
    +
  • +
    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. Their verification library is enough.

+

The six cases, with code

+
+
+ +
+
+

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.

+
+ +
+ + 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 + + + + + +
+
+ + + + +
+
+
+
+
-

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.

-

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.tsChoose how the transaction ends
-
await client.query("BEGIN");await client.query(
+        
+
+
+
await client.query("BEGIN");await client.query(
   `UPDATE invoices SET status = 'paid'
    WHERE id = $1`,
   [id],
@@ -833,49 +1393,87 @@ 

Queued in your transaction. Sent only if it commits.

tenantId, eventType: "invoice.paid", payload: { id }, -}, { 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
+
-
- - -

Nothing is sent until the transaction commits.

+ +

Nothing is sent until the transaction commits.

-

The bug it fixes

-

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.

+

ROLLBACK

+

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
-
+
+
+

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.

+
+

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

@@ -883,182 +1481,177 @@

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

-
+
-
-
-

Retries

-

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.

-
-
-
- Receiver behaviour - - - - -
- -
-
+

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.

-
invoice.paid
-
-
8attempts

over about 28 hours, from 5 seconds to 10 hours apart.

-
5days

of failures before an endpoint is disabled. One call turns it back on.

-
24hours

when old and new secrets both sign, so receivers rotate without dropping events.

-
0lost

and none duplicated across 20,000 webhooks and 4 competing workers in the load test.

+
8attempts

over about 28 hours, from 5 seconds to 10 hours apart.

+
5days

of failures before an endpoint is disabled. One call turns it back on.

+
24hours

when old and new secrets both sign, so receivers rotate without dropping events.

+
0lost

and none duplicated across 20,000 webhooks and 4 competing workers in the load test.

The same retry shape Svix uses, all configurable. On one laptop: about 13,000 sends and 5,500 deliveries per second. Reproduce it with pnpm bench.

-
+
-

Signatures

-

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.

+

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 have to invent a signing scheme and support it.

+

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

-
- - -
-
POST https://acme.com/webhooks
-
webhook-id: msg_…
-webhook-timestamp: …
-webhook-signature: v1,…
-
-…
+
+
+
POST https://acme.com/webhooks
+
webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W
+webhook-timestamp: 1794554750
+webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
+
+{"type":"invoice.paid","data":{"invoiceId":"in_123","amount":4200}}
-
- - Checking… +
+
+
+ + + + + +
+
+
+
import { Webhook } from "standardwebhooks";
+
+const wh = new Webhook(secret); // "whsec_..."
+const event = wh.verify(rawBody, headers);
+// throws if the signature fails or the timestamp is too old
+
from standardwebhooks.webhooks import Webhook
+
+wh = Webhook(secret)  # "whsec_..."
+event = wh.verify(raw_body, headers)
+# raises if the signature fails or the timestamp is too old
+
import standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
+
+wh, err := standardwebhooks.NewWebhook(secret)
+err = wh.Verify(body, r.Header)
+// err is set if the signature fails or the timestamp is too old
+
require "standardwebhooks"
+
+wh = StandardWebhooks::Webhook.new(secret)
+event = wh.verify(raw_body, headers)
+# raises if the signature fails or the timestamp is too old
+
$wh = new \StandardWebhooks\Webhook($secret);
+
+$wh->verify($body, $headers);
+// throws if the signature fails or the timestamp is too old
+
-

Demo secret whsec_…, generated for this page.

-
+
-

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.

+

Status, response body and timing for each request, stored next to your data. A customer-facing log or a support view is one SQL query away.

-
- -
-
-
-
-

In your terminal

-

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. Played at 2× speed.

-
- Get started -
-
- - -
+
-

Compared

+

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 make sense when you want someone else to run it. Hookgres fits when you’d rather keep webhooks next to your data.

- + - +
QuestionHookgresHosted service
QuestionHookgresHosted service
Where it runsYour app, your PostgresTheir cloud, via API
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.

@@ -1070,22 +1663,24 @@

Customer portal, coming to Hookgres Pro

-
+
+
-

Ship webhooks this afternoon.

-

Add the package, run migrate(), start sending. Or try the playground first: Postgres plus three fake customer endpoints (healthy, flaky and down) at localhost:3000.

+

Your next webhook ships in your next commit.

+

Add the package, run migrate(), start sending. Or watch it handle failure first: the playground sends to three test endpoints (healthy, flaky, down).

-
+
Playground
$ git clone https://github.com/NickolasBenakis/hookgres
 $ cd hookgres
 $ docker compose up
+
@@ -1104,7 +1699,6 @@

Ship webhooks this afternoon.

- + diff --git a/site/motion.js b/site/motion.js new file mode 100644 index 0000000..59e6ead --- /dev/null +++ b/site/motion.js @@ -0,0 +1,470 @@ +// 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"); + const copyEl = document.querySelector(".hero-copy"); + const stageEl = document.querySelector(".hero-stage"); + const capsEl = document.querySelector(".hero .caps"); + if (hero && canvas && copyEl && stageEl && capsEl) { + 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 && d.x > s.start) + 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 tail = Math.max(s.start, s.x - s.len); + 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(tail, y - 0.75, s.x - tail, 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; + // Streaks stay out of the text: right of the copy when the hero has two + // columns, below it when they stack. + const copy = copyEl.getBoundingClientRect(); + const sideBySide = copy.right < stageEl.getBoundingClientRect().left; + const minX = sideBySide ? copy.right - rect.left + 24 : 0; + const minRow = sideBySide + ? 1 + : Math.ceil((copy.bottom - rect.top) / GAP) + 1; + // ...and above the feature list under the hero. + const rows = Math.min( + Math.floor(h / GAP), + Math.floor((capsEl.getBoundingClientRect().top - rect.top) / GAP), + ); + const start = minX + Math.random() * Math.max(0, (w - minX) * 0.15); + if (rows > minRow) + streaks.push({ + row: + minRow + Math.floor(Math.random() * Math.max(1, rows - minRow)), + start, + x: start, + 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; + s.alpha = Math.min(1, (s.x - s.start) / 80, (w - s.x) / (w * 0.2)); + 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 shadow falls away from it. + const stage = document.querySelector(".hero-stage"); + if (stage && finePointer && !reduce) { + const card = stage.querySelector(".hero-code"); + const shadow = stage.querySelector(".hero-shadow"); + 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 * 16, + gy: -ny * 14, + }; + 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`); + shadow.style.setProperty("--gx", `${cur.gx.toFixed(2)}px`); + shadow.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: packets run the path of one commit, with a beat of rest between runs. + 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 = 6.4; + 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.setAttribute("data-step", el.dataset.step); + 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); + } + arrivals.commit = [0.72]; + + // Steps under the drawing. Untouched, the lit step follows the packets and its bar + // fills for the length of that phase. Hover, focus or press a step to hold the + // drawing on it; pressing pins it until pressed again. + const steps = [...sketch.querySelectorAll(".step")]; + const phases = [ + [0, 0.72], + [0.72, 1.0], + [1.0, 1.75], + [1.75, CYCLE], + ]; + let pinned = 0; + let hoverStep = 0; + const focusOn = (n) => { + if (n) svg.dataset.focus = String(n); + else delete svg.dataset.focus; + }; + const syncFocus = () => focusOn(hoverStep || pinned); + for (const [i, button] of steps.entries()) { + const n = i + 1; + button.addEventListener("pointerenter", (event) => { + if (event.pointerType !== "mouse") return; + hoverStep = n; + syncFocus(); + }); + button.addEventListener("pointerleave", () => { + hoverStep = 0; + syncFocus(); + }); + // Keyboard focus holds the step; a tap's focus doesn't, so unpinning works on touch. + button.addEventListener("focus", () => { + if (!button.matches(":focus-visible")) return; + hoverStep = n; + syncFocus(); + }); + button.addEventListener("blur", () => { + hoverStep = 0; + syncFocus(); + }); + button.addEventListener("click", () => { + pinned = pinned === n ? 0 : n; + for (const [j, b] of steps.entries()) + b.setAttribute("aria-pressed", String(pinned === j + 1)); + syncFocus(); + // On narrow screens the drawing scrolls; bring the pressed step's part into view. + const scroller = sketch.querySelector(".sketch-scroll"); + if (pinned && scroller.scrollWidth > scroller.clientWidth) { + const ratio = scroller.scrollWidth / 1200; + const left = [24, 300, 740, 960][pinned - 1] * ratio - 16; + scroller.scrollTo({ left, behavior: reduce ? "auto" : "smooth" }); + } + }); + } + let shown = 0; + const followPackets = (t) => { + const held = hoverStep || pinned; + const n = held ? 0 : phases.findIndex(([a, b]) => t >= a && t < b) + 1; + if (n !== shown) { + for (const [i, b] of steps.entries()) + b.toggleAttribute("data-active", i + 1 === n); + shown = n; + } + if (n) { + const [a, b] = phases[n - 1]; + steps[n - 1].style.setProperty("--p", ((t - a) / (b - a)).toFixed(3)); + } + }; + + 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); + } + followPackets(t); + }; + + 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 { + whileVisible(sketch, frame, "0px"); + } + } +}