diff --git a/.gitignore b/.gitignore index 4bb4556..106a8c4 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,36 @@ dist-ssr *.njsproj *.sln *.sw? + +# Benchmarks live in their own repo: irinanazarova/anycable-socketio-benchmarks +benchmark/ + +# Tooling / Claude Code session artifacts +.claude/ +.playwright-mcp/ + +# Scratch / audit notes that aren't part of the site +TODO-*.md +llm-*.md +*-audit.md + +# Scratch directory for ephemeral notes, drafts, research dumps. +# Anything inside is ignored. Use this instead of leaving stray files +# at the repo root. +tmp/ + +# Editor settings +.zed/ + +# Test screenshots / page captures at repo root (real images live under src/) +/*.png +/*.jpeg +/*.jpg + +# Stray benchmark / report HTML dumps at repo root +/*-report.html +/*-benchmark.html + +# package-lock.json is generated locally; project bundles via Vite +package-lock.json +.gstack/ diff --git a/netlify.toml b/netlify.toml index 4a3cf70..52b95c8 100644 --- a/netlify.toml +++ b/netlify.toml @@ -3,6 +3,21 @@ publish="/dist" command="yarn build" +# Preserve SEO from the original /compare/socket-io URL after we +# renamed the slug to /compare/nodejs-websocket for organic +# discoverability ("Node.js WebSocket server" / "Socket.io alternative" +# queries). The new page is the canonical home; old inbound links +# transfer their rank via the 301. +[[redirects]] + from = "/compare/socket-io" + to = "/compare/nodejs-websocket" + status = 301 + +[[redirects]] + from = "/compare/socket-io/" + to = "/compare/nodejs-websocket/" + status = 301 + [[redirects]] from = "/anycasts/anycable-v1-4-reliable-real-time-for-all/" to = "https://blog.anycable.io/p/anycable-v14-reliable-real-time-for-all" diff --git a/package.json b/package.json index 300cdcb..c4bab26 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "MIT", "type": "module", "scripts": { - "dev": "vite --host", + "dev": "vite --host --strictPort", "build": "vite build", "preview": "vite preview" }, diff --git a/src/blog/anycable-vs-socket-io/index.md b/src/blog/anycable-vs-socket-io/index.md deleted file mode 100644 index 5e5ae86..0000000 --- a/src/blog/anycable-vs-socket-io/index.md +++ /dev/null @@ -1,108 +0,0 @@ -# AnyCable vs Socket.io: built-in reliability vs roll-your-own - -April 1, 2026 -{date} - -
- -Socket.io is the most popular WebSocket library in the JavaScript ecosystem. It's battle-tested, well-documented, and free. So why would you use AnyCable instead? Because Socket.io doesn't guarantee delivery — and in production, that's the problem that breaks everything else. -{intro} - -
- -## Socket.io doesn't guarantee delivery - -This is the core difference between the two tools, and everything else flows from it. - -Socket.io provides **at-most-once delivery**. Their own [documentation][socketio-delivery] says it plainly: "if the connection is broken while an event is being sent, then there is no guarantee that the other side has received it." There is no retry, no buffering for disconnected clients, and no catch-up on reconnection. - -AnyCable provides **at-least-once delivery**. Publications are stored in logs. The client tracks its stream position. On reconnection, it automatically recovers every missed message. Your application code doesn't change — you broadcast messages, and every client receives every one of them, even through disconnections. - -This matters more than you think. WebSocket connections are not as reliable as they appear in development. In production, micro-disconnections happen on stable connections, longer interruptions occur during commutes and subway rides, and every server deploy severs active connections. Without delivery guarantees, every one of these events silently corrupts your client state. - -### What this looks like in practice - -**Live chat:** A user enters a tunnel for 3 seconds. Two messages arrive in the group chat during that window. With Socket.io, those messages are gone — the user sees an incomplete conversation. With AnyCable, the client catches up automatically on reconnection. - -**LLM/AI streaming:** You're streaming an AI response word by word. The client briefly loses connection mid-sentence. With Socket.io, the response is truncated or garbled — chunks are lost with no recovery. With AnyCable, every chunk is recovered in order. As we described in our article on [the pitfalls of LLM streaming][llm-streaming], this is a real production problem that breaks AI-powered features. - -**Real-time dashboards:** A monitoring dashboard shows live metrics. A 200ms network blip causes a gap in the data. With Socket.io, that gap is permanent. With AnyCable, the missed data points are recovered and the chart stays complete. - -**Deploys:** You ship code. With Socket.io, every WebSocket connection is severed — every client must reconnect, and any in-flight messages are lost. With AnyCable, the Go-based WebSocket server is a separate process from your application. When you deploy your app, the WebSocket server stays up. Connections are never interrupted. Users never notice a deployment. - -As Doximity engineers [described on the On Rails podcast][doximity-podcast]: - -> "AnyCable allows them to keep that connection open. That Go service stays up, and you can continue shipping your Rails application as normal." - -## A standalone server, not an embedded library - -Socket.io is a library you embed in your Node.js application. It runs in the same process as your business logic, competing for the same CPU and memory. - -AnyCable is a standalone server written in Go. It handles WebSocket connections independently — your application communicates with it via HTTP API. This separation has three consequences: - -1. **Scale independently.** Need more WebSocket capacity? Scale AnyCable. Need more app server capacity? Scale your app. They don't compete for resources. - -2. **Deploy independently.** Ship code to your app without touching WebSocket connections. This is why connections survive deploys. - -3. **Use any backend language.** Socket.io locks you into Node.js. AnyCable works with Rails, Laravel, Node, Python/FastAPI, or any backend that can make HTTP requests. Broadcast a message from any language: - -``` -POST /api/v1/broadcasts -{ "stream": "chat/42", "data": "{\"message\": \"hello\"}" } -``` - -### Why Go for WebSockets - -WebSocket connections are long-lived — a user can hold one open for hours. In Node.js, each connection consumes meaningful memory in the V8 runtime. At 10,000 concurrent connections, this adds up fast. - -Go's goroutine-based concurrency model handles long-lived connections with minimal overhead. AnyCable serves 10,000+ concurrent connections per server using a fraction of the memory that Node.js or Ruby would need. And Go compiles to a single binary — no `node_modules`, no runtime version conflicts. - -## What you don't have to build - -With Socket.io, you get a WebSocket transport and rooms. Everything else is your responsibility: - -| What you need | Socket.io | AnyCable | -|---------------|-----------|----------| -| Reliable delivery | No (at-most-once) | Built-in (at-least-once) | -| Missed message recovery | No catch-up mechanism | Automatic on reconnection | -| Presence (who's online) | Build it yourself | Built-in | -| Authentication | Build your own middleware | JWT, signed streams | -| Pub/sub clustering | Redis adapter (separate setup) | Embedded NATS (zero extra infra) | -| Monitoring | Add your own | Prometheus & StatsD built-in | -| Binary compression | No | Yes (Pro) | -| Deploy resilience | Not possible (same process) | Built-in (separate server) | - -Each of these is weeks of engineering work to build properly, and months to battle-test in production. AnyCable ships them as built-in primitives because we've been doing this since 2017. - -## Proven at scale - -AnyCable has been powering real-time features in production since 2017, for companies like: - -- **Doximity** — telehealth for 80% of US physicians -- **CoinGecko** — cryptocurrency market data at massive scale -- **Jobber** — field service management ($167M revenue) -- **Headway** — mental health therapy ($2.3B valuation) -- **Circle** — community platform ($30M+ raised) -- **ClickFunnels** — sales funnels (~$265M revenue) - -The project is actively developed with regular releases, a dedicated team, commercial Pro support, and a growing ecosystem including [Laravel support][laravel-post], [Pusher protocol compatibility](https://docs.anycable.io/guides/pusher), and the emerging [Durable Streams](https://docs.anycable.io/guides/durable_streams) standard. - -## When Socket.io is the right choice - -To be fair: if you're building a small Node.js app, prototyping, or need full control over a custom protocol, Socket.io is a fine choice. It's well-documented, has a massive community, and is free. - -But if you need delivery guarantees — and in production, you almost certainly do — you'll end up building them yourself on top of Socket.io. AnyCable gives you delivery guarantees, presence, authentication, and scaling out of the box, with any backend language, tested in production by companies serving millions of users. - -## Get started - -- [Documentation](https://docs.anycable.io) -- [Rails getting started guide](https://docs.anycable.io/rails/getting_started) -- [Laravel guide](https://docs.anycable.io/guides/laravel) -- [JavaScript/TypeScript client](https://github.com/anycable/anycable-client) -- [GitHub](https://github.com/anycable/anycable) -- [AnyCable Pro](https://plus.anycable.io/pro) — free 2-month trial - -[socketio-delivery]: https://socket.io/docs/v4/delivery-guarantees -[llm-streaming]: https://evilmartians.com/chronicles/anycable-rails-and-the-pitfalls-of-llm-streaming -[doximity-podcast]: https://podcast.rubyonrails.org/2462975/episodes/17653501 -[laravel-post]: https://evilmartians.com/chronicles/anycable-for-laravel diff --git a/src/compare/nodejs-websocket/index.html b/src/compare/nodejs-websocket/index.html new file mode 100644 index 0000000..0027d4e --- /dev/null +++ b/src/compare/nodejs-websocket/index.html @@ -0,0 +1,981 @@ + + + {{> dochead pageTitle="Node.js WebSocket Server Comparison: Socket.io vs uWebSockets.js vs AnyCable (2026 Benchmark)" pageDescription="Benchmark of five Node.js WebSocket setups on identical hardware: default Socket.io, Socket.io with Connection State Recovery, uWebSockets.js, AnyCable OSS, AnyCable Pro. Compared on memory per connection, broadcast throughput, message delivery under WiFi-drop jitter, and deploy resilience. Includes a self-hosted Pusher and Ably alternative for teams who want flat pricing. All numbers reproducible from the open-source benchmark repo." pageUrl="https://anycable.io/compare/nodejs-websocket"}} + +
+ {{> header}} +
+ + {{!-- Hero — three at-a-glance cards, one per load-bearing rubric + finding. Each compares the headline metric across the four + tested setups; the page below lays out the methodology and + the next two rubrics (operations + optionality). --}} +
+
+
+

Looking for a Socket.io alternative for production Node.js?

+

+ Socket.io vs uWS vs AnyCable +

+

+ A 2026 benchmark of five WebSocket setups for Node.js, TypeScript, Bun, and Deno apps. Self-hosted alternative to Pusher Channels and Ably included. +

+
+ +
+
+
Latency: p99 roundtrip @ 10k connections
+
+
+
Socket.io + CSR
+
349 ms
+
+
+
uWebSockets.js
+
292 ms
+
+
+
AnyCable
+
880 ms
+
+
+ +
+ +
+
Reliability: % delivered under WiFi jitter
+
+
+
Socket.io + CSR
+
76%
+
+
+
uWebSockets.js
+
35%
+
+
+
AnyCable
+
100%
+
+
+ +
+ +
+
Footprint: RAM per idle connection
+
+
+
Socket.io caps ~120k/node
+
~52 KB
+
+
+
AnyCable Pro replay included
+
18 KB
+
+
+
uWS bare wire
+
5.4 KB
+
+
+ +
+
+ +
+

+ Your TS/JS application doesn't want to sit still: it needs WebSockets for all sorts of real-time messaging, streaming from LLMs, and collaborative features. What to choose? We benchmarked Socket.io, uWebSockets, and AnyCable across three rubrics: latency + throughput, reliability, and scalability. If we want to dance, let's dance smoothly. +

+

+ Same hardware on Railway, reproducible from the open-source bench repo. +

+ +
+
+
+
+
+
+
+

What we compare#

+

+ Five production-shaped options, same hardware: +

+
    +
  • Default Socket.io — baseline popular option.
  • +
  • Socket.io + CSR — Socket.io with Connection state recovery, the opt-in for delivery and order guarantees.
  • +
  • uWebSockets.js + topics — the “just use uWS” alternative, using its built-in subscribe/publish API.
  • +
  • AnyCable OSS — a separate Go binary your app broadcasts to over HTTP, broker built in.
  • +
  • AnyCable Pro — same protocol as OSS; denser per-connection memory, shared replay state, commercial license.
  • +
+ +
+
+
+ + + + + +
+ + + + + +
+ +
// Server
+const io = new Server(httpServer);
+
+io.on('connection', (socket) => {
+  socket.on('subscribe', (t) => socket.join(t));
+});
+
+// Broadcast from anywhere:
+io.to('chat:42').emit('message', payload);
+
+// Trade-off: at-most-once delivery — messages
+// during a disconnect are gone.
+ +
// Server — same code, plus the CSR option
+const io = new Server(httpServer, {
+  connectionStateRecovery: {
+    maxDisconnectionDuration: 2 * 60 * 1000,
+  },
+});
+
+// Same broadcast call:
+io.to('chat:42').emit('message', payload);
+
+// Trade-off: replay buffers held in memory.
+// Adapter must support CSR (in-memory or pg).
+ +
// Server — uWS + built-in topics API
+const app = uWS.App();
+
+app.ws('/ws', {
+  message: (ws, msg) => {
+    const { topic } = JSON.parse(msg);
+    ws.subscribe(topic);
+  },
+});
+
+// Broadcast from anywhere:
+app.publish('chat:42', JSON.stringify(payload));
+
+// Trade-off: no replay, no built-in observability,
+// "bug reports only" maintainer posture.
+ +
// Your Node app stays put. anycable-go runs as
+// a separate Go process; you broadcast over HTTP:
+await fetch('http://anycable:8080/_broadcast', {
+  method: 'POST',
+  body: JSON.stringify({
+    stream: 'chat:42',
+    data: JSON.stringify(payload),
+  }),
+});
+
+// Trade-off: extra process to run; broadcast hop
+// over HTTP caps single-publisher throughput.
+ +
// Same broadcast code as OSS — just a different
+// binary (anycable-go-pro) for the WS server:
+await fetch('http://anycable:8080/_broadcast', {
+  method: 'POST',
+  body: JSON.stringify({
+    stream: 'chat:42',
+    data: JSON.stringify(payload),
+  }),
+});
+
+// Trade-off: commercial license; in return you get
+// the embedded broker, lower per-conn memory, and
+// horizontal scale with shared replay state.
+
+
+
+ +
+
+ + {{!-- Note on architecture — sits between "What we test" and Section 1. + Left: user-written prose explaining why we test in microservice mode. + Right: embedded-vs-microservice architecture diagram + the 10k + embedded test numbers that motivate the architectural choice. --}} +
+
+
+
+

Note on architecture#

+

+ Production realtime needs the WS server as a standalone service, not embedded in your Node app. Embedded Socket.io/uWS freeze every user for >2 seconds and lose messages on every app deploy. That's what the rest of the page compares: the WS layer as a separate process. +

+
+

What we tested: 3-node cluster, embedded Socket.io + Redis, rolling deploy (redeploy each node in turn, wait for it back, move on).

+

Result: every user sees a >2 s freeze on rolling deployment when their node restarts. 99.6% delivered. 2-3 lost per user during the gap.

+
+
+
+ {{!-- Architecture diagram: embedded mode (app + WS in one + Node process) vs microservice mode (separate WS service). + Adapted from the older Socket.io-vs-AnyCable diagram but + reframed around topology rather than vendor. --}} +
+ {{!-- Two stacked panels. Top = embedded mode (what we tested); + bottom = microservice mode (target setup). Typography is the + page's: Stem for box titles + descriptions, Martian Mono for + small labels + annotations. Color: accent red for the fragile + (embedded) path, dark for the stable (microservice) path. --}} + + Embedded mode (tested) vs microservice mode (target) + + {{!-- Font sizes (SVG units): 15 (box title) · 14 (description) + · 13 (mono label / annotation) · 12 (mono helper). All ≥12 + in SVG units; renders ~12–15px on screen. --}} + EMBEDDED + + + + + + NODE 1 + App + Socket.io + ~5,000 WS connections + + + + NODE 2 + App + Socket.io + ~5,000 WS connections + + + + Redis + PUB/SUB + + + + + + + DEPLOY → RESTART NODE + every WS drops + + {{!-- BOTTOM — Microservice mode: app + WS side-by-side --}} + STANDALONE SERVICE + + + + NODE 1 · APP + App only + Auth, routes, DB + + + + + HTTP + + + + NODE 2 · SOCKET.IO + WS only + 10,000 WS connections + + + DEPLOY → NODE 1 RESTARTS + all WS survive + +
Embedded mode vs standalone service. Embedded leads to WS freeze every time main app deploys.
+
+ + +
+
+ + {{!-- Evidence for the architecture argument: avalanche test. + Moved here from the standalone "Does the WS layer survive + deploys?" section since it's making the architecture point. --}} +
+
+

+ What happens when an in-process WS layer restarts. Same Socket.io / +CSR / uWS server on a 1 vCPU / 0.5 GB box, scaled from 5K to 25K idle clients, then a real railway redeploy on the app. “Recovery” is wall-clock until 95% reconnected. AnyCable runs the WS layer as a separate Go binary, so the app deploy never touches it. Methodology · thundering-herd reading. +

+
+
+
+

Past 25K, in-process WS can't recover from its own restart. Standalone WS doesn't restart, so there's nothing to recover from.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClientsSocket.io recovery (also +CSR, uWS)ReconnectedAnyCable OSS & Pro
5,0004.5 s100%0 s
10,0003.9 s100%0 s
15,0005.8 s98.5% (224 lost)0 s
20,0008.0 s96.2% (753 lost)0 s
25,000never0% (all 25K lost)0 s
+

Socket.io, +CSR, and uWS run the WS layer inside the Node app, so railway redeploy restarts it. Past ~20K connections the reconnect storm OOMs the new container. A bigger box just moves the cliff: uWS pushes it to ~90K. AnyCable runs WS as a separate Go binary; app deploys never touch it.

+
+
+
+
+
+ + {{!-- Section 1 — How fast is it? Latency at 1k/10k/100k, + broadcast throughput, whispers. 100k tier, uWS topics whispers, + AnyCable Pro whispers, and standalone hop overhead still TBD. --}} +
+
+
+
+

How fast is it?#

+

+ Latency is the floor of what realtime can feel like — the tempo your app dances to. Two things to measure: roundtrip latency (one publisher to all subscribers) and broadcast throughput (how many messages per second the server can fan out before that latency starts inflating). All in the standalone shape: WS server as a separate process, publisher posting over HTTP. +

+
+
+
+ + {{!-- Latency at 1k and 10k subscribers --}} +
+
+

Roundtrip latency

+

+ Same broadcast workload at two connection counts. The shape of the curve from 1k to 10k tells you whether the server has headroom or is approaching a wall. +

+
+
+
+

All five servers hold the tail below 1 s at 10k subscribers. uWS leads on the p50 floor; AnyCable Pro holds the tightest p99.

+ + + + + + + + + + + + + + + +
Setup p50 / p991k subs10k subs
Socket.io default11 / 20 ms88 / 176 ms
Socket.io + CSR12 / 24 ms88 / 349 ms
uWS topics8 / 17 ms61 / 292 ms
AnyCable OSS10 / 26 ms236 / 880 ms
AnyCable Pro11 / 23 ms234 / 694 ms
+

1K and 10K subscribers, 100 broadcasts over 90 s (1K) / 130 s (10K), 500 ms intervals. uWS's single-writer app.publish can fall into a backpressure path on contended infrastructure that pushes p99 to several seconds; the value here is the well-behaved case, the saturated case has been observed at 4-11 s.

+
+
+
+ + {{!-- Throughput sub-section: max sustainable msg/sec at the + 1M-delivery target. All options as standalone services + (external HTTP publisher, pool=16), 10K subscribers, + 100% delivery required. --}} +
+
+

Broadcast throughput

+

+ Target 1M deliveries/sec to 10K subscribers, 100% delivery required. Publisher is a separate process posting to the WS server's HTTP /_broadcast at concurrency 16. The result is what each server sustains end-to-end. +

+
+
+
+

All five servers sustain 1M deliveries with 100% delivery. uWS is fastest at the floor; CSR and AnyCable hold the tightest tails. Default Socket.io's tail is the loosest.

+ + + + + + + + + + + + + + + + +
Setup 10K subs, 1M deliveries, HTTP pool=16Deliveredp50p99
Socket.io default100%0.74 s3.93 s
Socket.io + CSR100%0.50 s2.26 s
uWS100%0.22 s3.17 s
AnyCable OSS100%0.36 s3.13 s
AnyCable Pro100%0.37 s3.93 s
+

External publisher posts to /_broadcast at concurrency 16, 100 messages every 10 ms (target 1M deliveries/sec). uWS's app.publish is the fastest C++ hot path. AnyCable OSS and Pro track each other on raw msg/sec; Pro's wins are memory and the embedded broker. Default Socket.io's tail is roughly 2× Pro's at the same target.

+
+
+
+ + {{!-- Whispers sub-section: client-to-client without server roundtrip. + This is where AnyCable plays in the Liveblocks/Yjs/PartyKit category, + not just the WS-server category. --}} +
+
+

Whispers: client-to-client updates that bypass the backend

+

+ Cursors, typing indicators, presence pings. These travel client → WS server → peers without invoking your app. AnyCable ships it as a native primitive; Socket.io emulates with rooms; uWS with topics. The differences show up under load. +

+
+ This is where AnyCable competes with Liveblocks, Yjs providers, and PartyKit, not just with WS libraries. If your product has live cursors or shared selections, this row matters more than raw broadcast throughput. +
+
+
+
+

Socket.io rooms loses a third of whispers because every whisper goes back through the WS process. AnyCable and uWS skip that hop and deliver everything under 200 ms.

+ + + + + + + + + + + + + + + + +
Setup 1k clients, 10 rooms, 100 peers/room, 2 HzNative?Deliveredp50p99
Socket.io roomsEmulated62.3%1.78 s12.07 s
uWS topicsEmulated100%10 ms21 ms
AnyCable OSSNative100%18 ms78 ms
AnyCable ProNative100%18 ms64 ms
+

1K clients in 10 rooms (100 peers/room), 2 Hz whispers for 30 s. Each whisper fans out to 99 peers, so each client receives ~200 msgs/sec. Socket.io's rooms emulation re-emits through the server, Express can't keep up, and a third of the deliveries drop. AnyCable and uWS bypass the hop and stay under 200 ms p99. At 10K clients the bench-runner saturates client-side, which inflates latencies and confuses the delivery story; 1K × 100 peers/room is the cleanest comparison of the fanout paths.

+
+
+
+
+
+ + {{!-- Delivery guarantees — content + media halves --}} +
+
+
+
+

How reliable is delivery?#

+

+ Your users aren't always on a high-speed fiber network. Micro disruptions are mostly invisible to HTTP, but they're the deal-breaker for realtime: a missed beat in the music. Two things decide how it feels: how often messages are lost and how long the recovery window drags. Both come down to whether the protocol carries replay state. +

+

+ Default Socket.io is at-most-once; so is uWS. CSR adds replay (opt-in, adapter constraints). AnyCable ships reliable streams by default: per-stream history, epoch + offset, restart-survivable with NATS or Redis. Under simulated WiFi jitter (1 s TCP drops every ~15 s) at 10K subscribers, AnyCable delivers 100%, CSR resumes about 20% of reconnects cleanly to land at 76%, and the no-replay options drop most of the test once the server can't keep up with the reconnect storm. +

+
+
+
+

Replay protocols (AnyCable, CSR) deliver under heavy jitter. Default Socket.io and uWS drop most messages once reconnects start failing.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SetupDeliveryLost of 1.2Mp95p99 replay tail
Socket.io default27.5%870,0000.29 sno replay
Socket.io + CSR75.5%294,00080 s107 s
uWS topics34.6%785,000no replay
AnyCable OSS100%04.1 s6.2 s
AnyCable Pro100%04.1 s6.2 s
+

10K clients, 120 broadcasts, each client TCP-drops 1 s every ~15 s. 1.2M expected deliveries. Socket.io and uWS deliver well when reconnects succeed, but each WiFi blip is also a chance to lose the connection entirely; the page numbers reflect what users actually see when reconnect storms overlap with broadcast load. CSR resumes ~20% of disconnects cleanly; the rest fall back to live-from-now. CSR's lost count counts what fell outside the retention window; in-gap losses on the resumed connections are zero. AnyCable replays across the full disconnect, 100% delivery, sub-7 s p99. Methodology.

+
+
+
+
+
+ + {{!-- Use cases / impact --}} +
+
+
+
+

What this breaks

+
+

Lost messages cluster around network events — exactly when the user is watching. CSR resumes about 20% of reconnects cleanly, and its p99 replay tail stretches to nearly two minutes, which reads as “the app stopped working.” AnyCable replays the same messages in about 6 seconds, with full delivery. For sequential workloads, both loss and delay break the flow.

+
+
+
+
+
+ Live chat & notifications + Messages disappear during network blips. Users see incomplete conversations, no indication anything is missing. +
+
+ LLM / AI streaming + Responses stream word by word. A mid-sentence disconnect leaves output truncated or garbled — chunks lost, no recovery. +
+
+ Real-time collaboration & presence + Lost operations make documents diverge silently. Cursors jump, presence flickers; users lose trust in what they see. +
+
+ Dashboards & monitoring + Permanent gaps in time-series data. The 200 ms blip during a traffic spike is the data point you needed. +
+
+
+
+
+
+ + {{!-- Connection capacity --}} +
+
+
+
+

How efficient is it to scale?#

+

+ Your user base will grow and your realtime layer needs to grow with it. The question isn't how cheap is this today, it's when we 10× users, does the box cliff before we have time to react? The numbers below come from the single-node idle-connections load test on a 32 vCPU / 32 GB Railway box, 1M-connection target. +

+
+
+
+

uWS holds the most connections per byte. AnyCable Pro is the lightest setup that includes built-in replay. Socket.io tops out around 120K.

+ + + + + + + + + + + + + + + + +
SetupConnections heldRAM/connCPU peak % of 1 vCPUReplay?
Socket.io (1M attempted)119,826~52 KBn/ano
AnyCable OSS821,87734 KB9.8%yes
AnyCable Pro822,03718 KB7.9%yes
uWebSockets.js1,018,3665.4 KBn/ano
+

Ramp to 1M connections at 200/sec, hold 2 min. CPU peak measured on the WS server during the hold window via Railway metrics (CPU traces for uWS and Socket.io weren't captured during these runs). uWS is bare wire, no broker. AnyCable Pro is the lightest with replay built in; the extra KB/conn covers per-stream history and reconnect-resume.

+
+
+
+ +
+
+ + {{!-- Feature comparison --}} +
+
+
+
+

What you don't have to build

+

+ Socket.io gives you WebSocket transport and rooms. uWebSockets.js gives you a faster transport. Both stop there. Everything else — reliability, presence, auth, clustering, monitoring — is weeks of engineering plus months of hardening, on a single Node event loop you also have to keep alive across deploys. +

+

+ AnyCable ships these as primitives, hardened in production since 2017, in a process you don't have to restart when your app deploys. +

+
+
+
+

Socket.io and uWS give you transport. AnyCable gives you the whole realtime framework: reliability, presence, auth, deploy resilience.

+ + + + + + + + + + + + + + + + + + + + + + + + +
FeatureDefault
Socket.io
Socket.io
+ CSR
uWeb-
Sockets.js
AnyCable
OSS & Pro
Reliable deliveryNoYes (opt-in)NoYes (default)
Replay latency p99lost~107 slost~6 s
Survives server restartNoRedis Streams / MongoNoNATS / Redis
Multi-node setupRedis pub/subRedis pub/sub incompat.DIY (Redis)Any broker
No external broker requiredRedis requiredRedis Streams requiredDIYEmbedded NATS (Pro)
Deploy resilienceAll dropAll dropAll dropConnections survive
Graceful drain on restartNoneNoneNoneConfigurable (slow-drain Pro)
Presence trackingDIYDIYDIYBuilt-in
AuthenticationDIYDIYDIYJWT, signed streams
Backend languageNode.js onlyNode.js onlyNode.js onlyAny (HTTP API)
Binary wire formatThird-party pluginThird-party pluginDIYmsgpack, protobuf (Pro)
MonitoringAdmin UIAdmin UIDIYPrometheus & StatsD
+

uWS fixes Socket.io's wire overhead. Everything beyond raw transport is still DIY. AnyCable OSS and Pro share every feature here; Pro differentiates on memory, embedded broker, and commercial support.

+
+
+
+
+
+ + {{!-- Run AnyCable in your stack — demos + JS libs + serverless. --}} +
+ +
+ + {{!-- FAQ — accordion. Native
so it works without JS; + closed-by-default keeps the page short for skimmers. --}} +
+
+
+
+

FAQ#

+
+
+ Does AnyCable replace Socket.io, or work alongside it? +
It replaces Socket.io. Your Node.js app broadcasts to AnyCable via HTTP instead of calling io.emit(). Clients connect to AnyCable over its protocol (actioncable-v1-ext-json, via @anycable/core) instead of socket.io-client.
+
+ +
+ What's the operational cost of running AnyCable? +
One extra process — a single Go binary (or Docker container). It's stateless for broadcasts and scales horizontally. No database, no custom build. Defaults work for most apps; Redis or NATS can be added for multi-node pub/sub.
+
+ +
+ How does AnyCable compare on performance? +
Three workloads on the same 32 vCPU / 32 GB Railway box, every option as a standalone WS service.

1M-connection idle: uWS 1,018,366 / 5.45 GB. AnyCable Pro 822,037 / 14.8 GB (lightest with built-in replay). AnyCable OSS 821,877 / 28.3 GB. Socket.io caps at 119,826 (Node event loop saturates handshakes).

10K reconnecting clients under WiFi jitter: AnyCable delivers 100% (p99 ~6 s replay tail). Socket.io+CSR resumes about 20% of reconnects cleanly, lands at 76% delivery with a much longer replay tail. Default Socket.io and uWS drop most of the test (27% and 35%) once the server can't absorb the reconnect storm.

Broadcast throughput at 1M deliveries: all five sustain 100% delivery; uWS holds the floor at 0.22 s p50, AnyCable Pro and CSR hold the tightest p99 around 3 s. Reproduce.
+
+ +
+ What about uWebSockets.js? It's faster than Socket.io. +
uWS is genuinely faster on the wire, we measured it. At 1M idle connections it uses 5.45 GB (5.4 KB per conn) vs AnyCable Pro's 14.8 GB at 822K (18 KB per conn). The “10× faster than Socket.io” claim is honest. But uWS is a WebSocket library, not a realtime framework: no replay buffer, no broker, no separate-process deploy resilience. Under jitter it drops down to 35% delivery, similar to default Socket.io, because both are at-most-once and embedded in the same Node process as your app. uWS solves “Socket.io's wire is too heavy.” It doesn't solve “we lose messages during disruption” or “every deploy hits our users.”
+
+ +
+ Can I use AnyCable for streaming LLM responses? +
Yes — message ordering and durable streams are exactly what token streaming needs. If a user briefly disconnects mid-response, AnyCable replays the missed chunks in order from the last offset they saw. Out-of-order arrivals (which corrupt LLM output) don't happen because each stream is monotonically ordered. The Next.js + Twilio + OpenAI demo linked above shows this end-to-end.
+
+ +
+ What backends can use AnyCable? +
Any. Anycable-go is a standalone server with an HTTP broadcast API; your app pushes messages over plain HTTP. Node.js, Laravel, FastAPI, Django, Go, Elixir — anything that can issue an HTTP POST works. There is no language SDK requirement.
+
+ +
+ Can I self-host AnyCable for HIPAA / SOC2 compliance? +
Yes. anycable-go runs entirely on your infrastructure — no data flows through third-party servers, no shared cloud. Multiple healthcare and fintech teams self-host AnyCable for this reason: real-time features (patient-doctor chat, live data feeds) without data ever leaving controlled environments. For HIPAA specifically, deploy AnyCable inside your existing PHI boundary; it's just another service on your network.
+
+ +
+ Is AnyCable open source? +
Yes, MIT-licensed since 2017. anycable/anycable on GitHub. A commercial Pro tier adds broker features (long-term history, embedded NATS) and priority support — free for small deployments.
+
+ +
+ Is there a managed (hosted) AnyCable? +
Yes. AnyCable+ is the managed tier — zero ops, free for early users, paid plans for scale. Same protocol and feature surface as self-hosted, so you can switch in either direction without changing app code.
+
+ +
+ How does the cost compare to Pusher or Ably? +
Pusher and Ably charge per concurrent connection — costs scale linearly with your user base. AnyCable Pro (self-hosted, unlimited connections and instances) is a flat-rate annual license — $1,490/yr with a 2-month free trial. AnyCable+ Managed is free for early users. At 10K+ concurrent connections, the flat-rate or self-hosted options typically save thousands per month versus per-connection pricing.
+
+ +
+ How do you handle authentication? +
Signed JWT tokens or signed stream names, issued by your application. AnyCable verifies the signature on connect and channel subscribe — your app stays the source of truth for identity without being on the hot path.
+
+ +
+ What happens if AnyCable itself restarts? +
Less often than app deploys (you don't ship anycable-go on every code change), but it does happen — version upgrades, config changes, host reboots. The behavior depends on the broker: with an external broker (NATS JetStream or Redis Streams), replay state survives the restart, so clients reconnect and resume from the last offset they saw. With the default in-memory broker (or AnyCable Pro's embedded broker), replay state is lost on restart and clients fall back to “live from now” — same delivery profile as a cold reconnect to default Socket.io. For production, run multiple anycable-go instances behind a load balancer and a shared broker; restart one at a time during upgrades and clients seamlessly reconnect to the others.
+
+ +
+ How do I run anycable-go in production? +
One Go binary. Docker images at hub.docker.com/r/anycable/anycable-go (and anycable-go-pro for Pro). Minimum to run: docker run -p 8080:8080 anycable/anycable-go --broadcast_key=YOUR_SECRET. Configurable via flags or ANYCABLE_* env vars. Health endpoint at /health, Prometheus metrics at /metrics. Graceful drain on SIGTERM (configurable via --shutdown_timeout) so rolling deploys don't drop connections. Behind a load balancer with sticky sessions for multi-instance setups. Helm chart and Fly/Railway templates linked from the docs.
+
+
+
+
+
+
+ + {{!-- CTA — two paths: run locally with docs targeted at Node teams, + or try the managed free tier. --}} +
+
+

Run AnyCable in your Node app

+

+ Self-host the Go binary alongside your Node service, or skip the deploy and start on the free managed tier. +

+ +
+
+ +
+ {{> footer }} +
+ + {{!-- FAQPage structured data — LLM and search-engine readable Q-A pairs. + Mirrors the visible
accordion above. --}} + + {{!-- TechArticle structured data — gives the page itself an + author/publisher/date signal so Gemini's engineering-blog + retrieval path has something to attach to. --}} + + + + + diff --git a/src/compare/nodejs-websocket/style/index.html b/src/compare/nodejs-websocket/style/index.html new file mode 100644 index 0000000..a37f958 --- /dev/null +++ b/src/compare/nodejs-websocket/style/index.html @@ -0,0 +1,319 @@ + + + {{> dochead pageTitle="Compare-page style index — system reference" pageDescription="Internal style index for the AnyCable compare-page design system. Every block authors should reach for, rendered once with the HTML structure visible. Copy from here, paste into any compare/* page."}} + +
+ {{> header}} +
+ + {{!-- ============================================================ + SYSTEM REFERENCE + Every section below shows: (1) what it looks like rendered, + (2) the HTML structure to copy, (3) when to reach for it. + Source of truth — if a pattern isn't here, it isn't part of + the system yet. Add it here first, then to the page. + ============================================================ --}} + +
+

+ Compare-page system reference +

+

+ What authors writing or extending a comparison page should reach for. Every block here renders as it will on the live page. Copy the HTML next to each one. If a pattern is missing, add it to the spine (src/modules/blocks/compare-spine.scss) first, then add a demo entry here. +

+

+ All spine rules are scoped under .c-spine. The comparison page activates them by adding c-spine to its <main class='content compare-page'>. +

+
+ + {{!-- ============================================================ + 01 · TYPOGRAPHY + ============================================================ --}} +
+

01 · Typography

+ +

+ Display heading with vs +

+

Use for: the page H1. The c-vs span is a typographic operator between comparison terms.

+
<h1 class='compare-hero__title'>
+  Socket.io <span class='c-vs'>vs</span> AnyCable
+</h1>
+ +

Section heading

+

Use for: the top of every <section class='compare-rubric'>. Display sized.

+
<h2 class='compare-rubric__title' id='latency'>
+  How fast is it?
+  <a class='heading-anchor' href='#latency' aria-label='Permalink'>#</a>
+</h2>
+ +

Sub-section heading

+

Use for: H3 inside a long section.

+
<h3 class='compare-rubric__subtitle'>How latency scales</h3>
+ +

+ Body prose — capped at 580px line length. Inline code sits as a calm pill scaled to 0.8em so it optically aligns with Stem. Links carry the accent color. Strong for emphasis. Use t-mute for quieter text, t-tiny for 11px small print, t-accent for brand red, and t-num on numbers like 123,456 for tabular spacing. +

+
<p class='compare-prose'>
+  Body prose. Inline <code>code</code>, <strong>strong</strong>,
+  <a class='link' href='…'>link</a>, <span class='t-mute'>mute</span>.
+</p>
+
+ + {{!-- ============================================================ + 02 · LAYOUT + ============================================================ --}} +
+

02 · Section layout

+

+ Every section on the page is a compare-rubric. Two-column by default (prose left, media right); use --full for single-column sections like FAQ or appendix. +

+
<section class='compare-rubric'>
+  <div class='compare-rubric__wrapper'>
+    <div class='compare-rubric__section'>
+      <div class='compare-rubric__content'>
+        <h2 class='compare-rubric__title' id='…'>…</h2>
+        <p class='compare-prose'>…</p>
+      </div>
+      <div class='compare-rubric__media compare-rubric__media--align-top compare-rubric__media--sticky'>
+        <!-- a code block, table, diagram, or chart -->
+      </div>
+    </div>
+  </div>
+</section>
+ +

+ Media modifiers: --align-top aligns the media column to the top of the prose; --sticky pins it during long scroll. Combine both for the default benchmark rubric. +

+

+ Full-width: for sections without a media partner (FAQ, appendix, CTA, intro). +

+
<div class='compare-rubric__content compare-rubric__content--full'>…</div>
+
+ + {{!-- ============================================================ + 03 · RIGHT-SIDE ARTIFACTS + All four share the same outer container: #fafafa bg, #ececec + hairline border, 8px radius, 28/32 padding. + ============================================================ --}} +
+

03 · Right-side artifacts

+

+ Four kinds of content go in the media column. They share one visual container so the page rhythm stays consistent. +

+ +

Code block

+
// Server
+const io = new Server(httpServer);
+
+io.on('connection', (socket) => {
+  socket.on('subscribe', (t) => socket.join(t));
+});
+
<pre class='compare-code-block'><span class='c-com'>// comment</span>
+<span class='c-key'>const</span> foo = <span class='c-str'>'string'</span>;
+</pre>
+

+ Syntax tokens: c-key (keyword, amber), c-str (string, green), c-com (comment, slate italic), c-err (error, red). +

+ +

Framed data table

+
+ + + + + + + + + + + + + + + +
Setupp50p99
Socket.io default47 ms182 ms
Socket.io + CSR49 ms194 ms
uWebSockets.js31 ms98 ms
AnyCable24 ms71 ms
AnyCable Pro19 ms58 ms
+
+

Methodology note in mono small. Use compare-data-table__footnote directly below the frame.

+
<div class='compare-frame'>
+  <table class='compare-data-table compare-data-table--compact'>
+    <thead>…</thead>
+    <tbody>
+      <tr><td>Setup</td><td class='is-best'><strong>19 ms</strong></td></tr>
+      <tr class='is-row-best'>…</tr>
+    </tbody>
+  </table>
+</div>
+<p class='compare-data-table__footnote'>Methodology note.</p>
+

+ Cell states: is-best (green winner), is-worst (red loser), is-na (faded n/a), is-row-best (full-row tint), is-row-worst. +
Variant: compare-data-table--compact for tables with many columns. +

+
+ + {{!-- ============================================================ + 04 · ASIDES + ============================================================ --}} +
+

04 · Callout (aside)

+ +
+ Caveat or framing. Use the callout for a methodology note, caveat, or "how to read this" framing. Accent-red left rule signals "important context, not main body." +
+ +
+ Lists inside callouts get smaller spacing automatically. No need to inline-style anything. +
    +
  • 100% of users affected by every deploy
  • +
  • ~2-second freeze per user
  • +
  • 2–3 messages lost per user, per deploy
  • +
+
+ +
<div class='compare-callout'>
+  <strong>Caveat.</strong> Body text.
+</div>
+
+ + {{!-- ============================================================ + 05 · LISTS + ============================================================ --}} +
+

05 · Lists

+ +

Bullet list

+
    +
  • Default Socket.io — the baseline most teams ship today.
  • +
  • Socket.io + CSR — the in-place delivery upgrade from 4.6, opt-in.
  • +
  • uWebSockets.js + topics — the “just use uWS” alternative.
  • +
  • AnyCable OSS — a separate Go binary your app broadcasts to.
  • +
  • AnyCable Pro — same protocol; denser per-conn memory.
  • +
+ +

Ordered list

+
    +
  1. How fast — latency at 1k / 10k / 100k.
  2. +
  3. How reliable — delivery under WiFi-drop jitter.
  4. +
  5. How efficient to scale — load + 3-node deploy.
  6. +
+

+ Numbered <ol class='compare-prose'> renders with mono accent-red 01 / 02 / 03 markers. No inline styles needed. +

+
+ + {{!-- ============================================================ + 06 · CARDS + ============================================================ --}} +
+

06 · Cards

+ +

Hero card strip (top of page)

+
+
+
Latency p50 @ 10K
+
+
Socket.io
47 ms
+
uWS
31 ms
+
AnyCable
19 ms
+
+ Latency test +
+
+
Reliability delivery under jitter
+
+
Socket.io
87%
+
uWS
86%
+
AnyCable
100%
+
+ Reliability test +
+
+
Scalability downtime per deploy
+
+
Socket.io
~2 s
+
uWS
~2 s
+
AnyCable
0 s
+
+ Scalability test +
+
+ +

Impact / disqualification cards

+
+
+ Live chat & notifications + Messages disappear during network blips. Users see incomplete conversations. +
+
+ LLM streaming + Mid-sentence disconnects leave output truncated. Chunks lost, no recovery. +
+
+ +

Try-it card (link card with eyebrow)

+ +
JS client
+
@anycable/core, @anycable/web
+
The official client for Node.js, browsers, React Native.
+
+

+ t-eyebrow inside compare-try-card gets default margin-bottom. No inline styles needed. +

+
+ + {{!-- ============================================================ + 07 · FAQ ACCORDION + ============================================================ --}} +
+

07 · FAQ accordion

+
+
+ Does AnyCable replace Socket.io? +
It replaces it. Your app broadcasts to AnyCable via HTTP instead of io.emit().
+
+
+ What's the operational cost? +
One extra process. A single Go binary or Docker container. Stateless, scales horizontally.
+
+
+
<div class='compare-faq'>
+  <details class='compare-faq__item'>
+    <summary>Question?</summary>
+    <div class='compare-faq__answer'>Answer.</div>
+  </details>
+</div>
+
+ + {{!-- ============================================================ + 08 · INLINE VOCABULARY + ============================================================ --}} +
+

08 · Inline vocabulary

+ + + + + + + + + + + + + + + + + + + +
ClassUse for
c-vsThe operator between H1 comparison terms
linkInline link (accent-red, underline-on-hover)
t-muteQuieter text (gray)
t-tiny11px small print (often with t-mute)
t-metaMedium gray (footnotes)
t-strongPure black for emphasis
t-accentBrand red
t-numTabular-nums for aligned numbers
t-eyebrowMono uppercase label
c-key / c-str / c-com / c-errCode syntax tokens inside <pre> blocks
is-best / is-worst / is-naTable cell state
is-row-best / is-row-worstFull table row tint
+
+ +
+ {{> footer}} +
+ + diff --git a/src/images/logos/rangee.png b/src/images/logos/rangee.png deleted file mode 100644 index 1e660b7..0000000 Binary files a/src/images/logos/rangee.png and /dev/null differ diff --git a/src/images/logos/tasktag.png b/src/images/logos/tasktag.png deleted file mode 100644 index 1e660b7..0000000 Binary files a/src/images/logos/tasktag.png and /dev/null differ diff --git a/src/index.scss b/src/index.scss index c376e83..6b3ebce 100644 --- a/src/index.scss +++ b/src/index.scss @@ -29,4 +29,6 @@ @import './modules/blocks/blog.scss'; @import './modules/blocks/blog-header.scss'; @import './modules/blocks/demo.scss'; +@import './modules/blocks/compare.scss'; +@import './modules/blocks/compare-spine.scss'; @import './modules/common.scss'; diff --git a/src/js/components/Popup.ts b/src/js/components/Popup.ts index 9ff7dfe..b654791 100644 --- a/src/js/components/Popup.ts +++ b/src/js/components/Popup.ts @@ -8,12 +8,16 @@ export default class Popup { } init() { + // Popup host markup isn't on every page (e.g. /compare/socket-io + // has no try-now / contact-us popups). Skip silently if absent. + if (!this._popup) return; setTimeout(() => { this._popup.style.display = 'inherit'; //prevents flickering while loading }, 0); } open() { + if (!this._popup) return; const popupContainer = this._popup.querySelector( '.popup__container' ) as HTMLDivElement; @@ -24,6 +28,7 @@ export default class Popup { } close() { + if (!this._popup) return; const popupContainer = this._popup.querySelector( '.popup__container' ) as HTMLDivElement; diff --git a/src/js/components/demo.js b/src/js/components/demo.js index 8b781ed..ef61a9c 100644 --- a/src/js/components/demo.js +++ b/src/js/components/demo.js @@ -75,9 +75,13 @@ const scenario = [ }, ]; -const demo = new DemoController({ - element: document.querySelector("[data-controller='demo']"), - scenario, -}); - -demo.init(); +// Demo widget only exists on the home page. Skip wiring on pages +// where the host element isn't present (compare/, docs/, etc.). +const demoElement = document.querySelector("[data-controller='demo']"); +if (demoElement) { + const demo = new DemoController({ + element: demoElement, + scenario, + }); + demo.init(); +} diff --git a/src/modules/blocks/about-slide.scss b/src/modules/blocks/about-slide.scss index 4ff0c09..25f7917 100644 --- a/src/modules/blocks/about-slide.scss +++ b/src/modules/blocks/about-slide.scss @@ -51,6 +51,12 @@ $className: 'about-slide'; padding-top: 48px; } } + + // Use when the section has no media partner (e.g. FAQ). + &-full { + width: 100%; + border-right: none; + } } &__media { @@ -76,6 +82,23 @@ $className: 'about-slide'; margin-bottom: 48px; } + // Keeps the illustrating column visible while the reader scrolls through + // long left-column explanations. Pins from the top of the viewport + // (with header offset) and releases when its section ends. + &-sticky { + align-self: flex-start; + position: sticky; + top: 80px; // 60px page header + 20px breathing room + max-height: calc(100vh - 100px); + overflow-y: auto; + + @include mediaMax($tablet) { + position: static; + max-height: none; + overflow: visible; + } + } + @include mediaMax($tablet) { display: none; } @@ -125,6 +148,28 @@ $className: 'about-slide'; } } + // Calm inline code: smaller, faint background, never breaks the rhythm + // of the surrounding sans-serif text. + &__text code, + &__subtitle code, + &__content li code { + font-size: 0.85em; + font-weight: 500; + background: rgba(0, 0, 0, 0.05); + padding: 1px 6px; + border-radius: 4px; + vertical-align: 1px; + white-space: nowrap; + + // On narrow viewports, a long `io.to(room).emit(event, payload)` + // chunk overflows the line. Allow breaking inside the code token + // rather than letting it push past the viewport edge. + @include mediaMax($mobile) { + white-space: normal; + overflow-wrap: anywhere; + } + } + &__tabs { display: flex; flex-direction: row; diff --git a/src/modules/blocks/compare-spine.scss b/src/modules/blocks/compare-spine.scss new file mode 100644 index 0000000..96a996b --- /dev/null +++ b/src/modules/blocks/compare-spine.scss @@ -0,0 +1,2094 @@ +// ===================================================================== +// Compare-page SPINE — design system for /compare/* pages +// ===================================================================== +// +// QUICK REFERENCE for authors (writing or extending a compare page): +// +// See the live style index at: /compare/nodejs-websocket/style/ +// (Every block authors should reach for, rendered + with HTML.) +// +// Activation: add `c-spine` to the page's
+// so spine rules apply. +// +// ─── DESIGN TOKENS (CSS custom properties on .c-spine) ────────────── +// --c-font-display / --c-font-body Stem +// --c-font-mono Martian Mono +// --c-fs-display / -section / -sub / -body / -meta / -caption / -label +// --c-space-section / -block / -sub / -para / -bullet (8px scale) +// --c-prose-max / -prose-max-full prose width caps (580 / 680 px) +// --c-paper / -panel / -code-bg / -code-border surfaces +// --c-text / -text-meta / -text-quiet / -text-faint text grays +// --c-accent / -best / -worst accents +// --c-code-key / -str / -com / -err syntax tokens +// --c-radius 8px +// +// ─── LAYOUT (BEM) ─────────────────────────────────────────────────── +// .compare-rubric > __wrapper > __section > __content + __media +// .compare-rubric__content--full single-column section +// .compare-rubric__media--sticky pins media during scroll +// .compare-rubric__media--align-top align media to prose top +// +// ─── TYPOGRAPHY (use as class on element) ─────────────────────────── +// .compare-hero__title page H1 (display sized, +.c-vs span) +// .compare-rubric__title section H2 +// .compare-rubric__subtitle sub-section H3 +// .compare-prose body paragraph (max 580px) +// ol.compare-prose numbered list with mono 01/02/03 markers +// .compare-bullet-list bulleted list with red dot +// .c-vs typographic "vs" operator (inside H1) +// +// ─── RIGHT-SIDE ARTIFACTS (all share #fafafa frame) ───────────────── +// .compare-code-block naked light-tinted code +// .compare-code-tabs tabbed code with multiple panels +// .compare-frame container for a data table +// .compare-data-table transparent table inside .compare-frame +// .compare-arch-diagram SVG diagram in a framed figure +// .compare-bench-chart ASCII chart in a framed figure +// +// ─── ASIDES + CARDS ───────────────────────────────────────────────── +// .compare-callout accent-red left-rule aside +// .compare-hero-cards top results strip (three columns) +// .compare-impact-cards feature/disqualification grid +// .compare-try-card link card with eyebrow + title + desc +// .compare-quote-card customer quote with byline +// +// ─── INLINE UTILITIES ─────────────────────────────────────────────── +// t-mute / t-tiny / t-meta / t-strong / t-accent / t-num text qualifiers +// t-eyebrow mono label +// c-key / c-str / c-com / c-err code syntax +// is-best / is-worst / is-na table cell state +// is-row-best / is-row-worst full-row tint +// +// ─── RULES OF THE ROAD ────────────────────────────────────────────── +// 1. No inline style="" on content elements. If you need a pattern, +// add it to this spine (or the style index) first. +// 2. No magic numbers. Use the spacing scale (--c-space-*) and +// the 8px tokens (4/8/12/16/24/32) for any layout work. +// 3. Right-side artifacts must use the .compare-frame / __pre / __code +// surface. Don't paint your own light box. +// 4. Brand red is reserved for: links, "vs", winners (data), eyebrows. +// Don't use it as decoration. +// +// ─── ARCHITECTURE ─────────────────────────────────────────────────── +// Loaded after compare.scss in src/index.scss so overrides win on +// specificity ties. Rules scoped under .c-spine so they only apply +// to pages that opt in. +// ===================================================================== + +@import '../variables.scss'; +@import '../mixins.scss'; + +// ---------- Spine scope ---------------------------------------------- +.c-spine { + // ----- Tokens as CSS custom properties (live, devtools-tweakable) -- + --c-font-display: 'Stem', Arial, sans-serif; + --c-font-body: 'Stem', Arial, sans-serif; + --c-font-mono: 'Martian Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + + // Type scale (px). Six roles, one display, one body, two heading levels, + // one label voice (mono), and two demote sizes (meta + caption). + --c-fs-display: 64px; + --c-fs-section: 36px; + --c-fs-sub: 22px; + --c-fs-body: 19px; + --c-fs-meta: 15px; + --c-fs-caption: 13px; + --c-fs-label: 12px; + + --c-lh-display: 1.05; + --c-lh-section: 1.15; + --c-lh-sub: 1.3; + --c-lh-body: 1.6; + --c-lh-meta: 1.5; + --c-lh-caption: 1.4; + + --c-tracking-display: -0.02em; + --c-tracking-section: -0.01em; + --c-tracking-label: 0.08em; + + // Spacing scale (8px base). Use multiples; do not invent magic numbers. + --c-space-section: 96px; + --c-space-section-wide: 128px; + --c-space-block: 32px; // after H2 + --c-space-sub: 20px; // after H3 + --c-space-para: 24px; // after body paragraph + --c-space-bullet: 12px; // between bullet items + --c-space-inline-1: 4px; + --c-space-inline-2: 8px; + --c-space-inline-3: 12px; + --c-space-inline-4: 16px; + + // Prose width — Stripe Docs-tight, 580px is ~58–62 characters at 19px. + --c-prose-max: 580px; + --c-prose-max-full: 680px; + + // Surfaces — three materials, no dark. + --c-paper: #{$backgroundPrimaryColor}; // #fff + --c-panel: #{$backgroundSecondaryColor}; // #f5f5f5 + --c-code-bg: #fafafa; + --c-code-border: #ececec; + + // Hairlines and text grays. One gray scale, four steps. + --c-text: #1a1a1a; + --c-text-meta: #555; + --c-text-quiet: #777; + --c-text-faint: #bbb; + --c-border: #e8e8e8; + --c-border-soft: #f0f0f0; + + // Accents — accent red is the comparison voice, green is the winner. + --c-accent: #{$accentPrimaryColor}; // #f64343 + --c-best: #16a34a; + --c-worst: #b91c1c; + + // Muted syntax tokens (light code surface) — readable on #fafafa. + --c-code-key: #b45309; // amber-brown + --c-code-str: #166534; // deep green + --c-code-com: #6b7280; // slate + --c-code-err: #991b1b; // deep red + + --c-radius: 8px; + + // ----- Page base ---------------------------------------------------- + // Dotted notebook bg was removed — pure decoration, communicated + // nothing. Plain paper bg lets data + type carry the page. + font-family: var(--c-font-body); + font-size: var(--c-fs-body); + line-height: var(--c-lh-body); + color: var(--c-text); + background: var(--c-paper); + + // ----- Role classes ------------------------------------------------ + .c-display { + font-family: var(--c-font-display); + font-size: var(--c-fs-display); + line-height: var(--c-lh-display); + letter-spacing: var(--c-tracking-display); + font-weight: 700; + margin: 0 0 var(--c-space-block); + + @include mediaMax($mobile) { + font-size: 44px; + line-height: 1.08; + } + } + + .c-section { + font-family: var(--c-font-display); + font-size: var(--c-fs-section); + line-height: var(--c-lh-section); + letter-spacing: var(--c-tracking-section); + font-weight: 700; + margin: 0 0 var(--c-space-block); + + @include mediaMax($mobile) { + font-size: 28px; + } + } + + .c-sub { + font-family: var(--c-font-display); + font-size: var(--c-fs-sub); + line-height: var(--c-lh-sub); + font-weight: 600; + margin: 0 0 var(--c-space-sub); + } + + // Label — mono, uppercase, tracked, optional brackets via .c-label--bracket + // Used everywhere structural: eyebrows, figure numbers, table headers, + // hero card titles. This is the lab-notebook signature. + .c-label { + font-family: var(--c-font-mono); + font-size: var(--c-fs-label); + line-height: 1.4; + letter-spacing: var(--c-tracking-label); + font-weight: 500; + text-transform: uppercase; + color: var(--c-text-meta); + display: inline-block; + + &--bracket::before { + content: '[ '; + color: var(--c-text-faint); + } + &--bracket::after { + content: ' ]'; + color: var(--c-text-faint); + } + + &--accent { + color: var(--c-accent); + } + &--strong { + color: var(--c-text); + } + } + + // Eyebrow above a section title — semantic shortcut, .c-label sized + // and spaced as a block. + .c-eyebrow { + @extend .c-label; + display: block; + margin: 0 0 var(--c-space-inline-3); + } + + .c-prose { + font-size: var(--c-fs-body); + line-height: var(--c-lh-body); + margin: 0 0 var(--c-space-para); + max-width: var(--c-prose-max); + + // Inline code — calm pill, doesn't fight prose. + code { + font-family: var(--c-font-mono); + font-size: 0.85em; + font-weight: 500; + background: rgba(0, 0, 0, 0.045); + padding: 1px 6px; + border-radius: 4px; + vertical-align: 1px; + } + + strong { + font-weight: 600; + color: var(--c-text); + } + } + + .c-meta { + font-size: var(--c-fs-meta); + line-height: var(--c-lh-meta); + color: var(--c-text-meta); + margin: 0 0 var(--c-space-para); + max-width: var(--c-prose-max); + } + + .c-caption { + font-family: var(--c-font-mono); + font-size: var(--c-fs-caption); + line-height: var(--c-lh-caption); + color: var(--c-text-quiet); + letter-spacing: 0.02em; + } + + // ----- The "vs" operator — mono accent, small, optical-vertical ----- + // Use inside .c-display: vs + // Reads as a typographic operator, not a faded word. + .c-vs { + font-family: var(--c-font-mono); + font-size: 0.42em; // relative to parent .c-display + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--c-accent); + vertical-align: 0.45em; // optically center against descenders + margin: 0 0.25em; + } + + // ----- Layout primitive: .c-rubric two-col with sticky media -------- + .c-rubric { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 64px; + padding: var(--c-space-section) 64px; + border-bottom: 1px solid var(--c-border); + background: transparent; + + @include mediaMax($tablet) { + grid-template-columns: 1fr; + gap: var(--c-space-block); + padding: 56px 24px; + } + + &--full { + grid-template-columns: 1fr; + justify-items: center; + + > * { + max-width: var(--c-prose-max-full); + width: 100%; + } + } + + &__content { + // Prose lives capped at --c-prose-max so the eye lands at a stable + // x-position. The grid track is still 1fr; the cap is on the prose + // itself, not the column. + > * { + max-width: var(--c-prose-max); + } + > .c-figure, + > .c-table, + > .c-callout, + > .c-code { + max-width: none; + } + } + + &__media { + align-self: start; + + &--sticky { + position: sticky; + top: 80px; + max-height: calc(100vh - 100px); + overflow-y: auto; + + @include mediaMax($tablet) { + position: static; + max-height: none; + overflow: visible; + } + } + } + } + + // ----- Figure: labelled container for tables, code, charts ---------- + // [ FIG. 3 ] How latency scales with concurrent connections + // ┌─────────────────────────────────┐ + // │ figure body (table / code / svg) │ + // └─────────────────────────────────┘ + // Methodology footnote, in mono small. + .c-figure { + display: block; + margin: 0 0 var(--c-space-block); + + &__header { + display: flex; + align-items: baseline; + gap: var(--c-space-inline-3); + margin-bottom: var(--c-space-inline-3); + } + + &__label { + @extend .c-label; + @extend .c-label--bracket; + flex: 0 0 auto; + } + + &__title { + font-family: var(--c-font-body); + font-size: var(--c-fs-meta); + font-weight: 600; + color: var(--c-text); + line-height: var(--c-lh-meta); + } + + &__body { + // The body is whatever lives inside: table, code, chart. + // Nothing painted here — the contained component owns its surface. + } + + &__caption { + @extend .c-caption; + display: block; + margin-top: var(--c-space-inline-3); + max-width: 100%; + } + } + + // ----- Code block: light, muted syntax ------------------------------ + .c-code { + width: 100%; + margin: 0; + padding: 24px 28px; + background: var(--c-code-bg); + border: 1px solid var(--c-code-border); + border-radius: var(--c-radius); + color: var(--c-text); + font-family: var(--c-font-mono); + font-size: 13px; + line-height: 1.7; + white-space: pre; + overflow-x: auto; + + .c-code-key { + color: var(--c-code-key); + } + .c-code-str { + color: var(--c-code-str); + } + .c-code-com { + color: var(--c-code-com); + font-style: italic; + } + .c-code-err { + color: var(--c-code-err); + } + } + + // ----- Table ------------------------------------------------------- + // Mono headers (uppercase tracked), tabular-num body cells, best/worst + // tints. Lives inside .c-figure for the label + caption. + .c-table { + width: 100%; + border-collapse: collapse; + font-size: var(--c-fs-meta); + line-height: var(--c-lh-meta); + + th, + td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid var(--c-border-soft); + } + + th { + font-family: var(--c-font-mono); + font-size: var(--c-fs-label); + font-weight: 500; + letter-spacing: var(--c-tracking-label); + text-transform: uppercase; + color: var(--c-text-meta); + border-bottom: 1px solid var(--c-border); + background: var(--c-paper); + } + + td.c-num, + td .c-num { + font-family: var(--c-font-mono); + font-variant-numeric: tabular-nums; + font-weight: 500; + } + + .c-best { + color: var(--c-best); + font-weight: 600; + } + .c-worst { + color: var(--c-worst); + } + + tr.is-best td { + background: rgba(22, 163, 74, 0.04); + } + tr.is-worst td { + background: rgba(185, 28, 28, 0.04); + } + } + + // ----- Callout: soft tinted aside, left-rule + label --------------- + // [ NOTE ] Methodology caveat or framing for the data that follows. + .c-callout { + padding: 16px 20px; + margin: 0 0 var(--c-space-block); + background: #fbfbfb; + border-left: 3px solid var(--c-border); + border-radius: 0 4px 4px 0; + font-size: var(--c-fs-meta); + line-height: var(--c-lh-meta); + color: var(--c-text-meta); + + &__label { + @extend .c-label; + @extend .c-label--bracket; + display: block; + margin-bottom: 6px; + } + + strong { + color: var(--c-text); + font-weight: 600; + } + code { + font-family: var(--c-font-mono); + background: rgba(0, 0, 0, 0.05); + padding: 1px 5px; + border-radius: 3px; + } + } + + // ----- Card: hero-cards, try-it cards, impact cards ---------------- + // One card primitive, three uses. Variant via modifier classes. + .c-card { + display: block; + padding: 20px 22px; + background: var(--c-paper); + border: 1px solid var(--c-border); + border-radius: var(--c-radius); + text-decoration: none; + color: inherit; + transition: border-color 200ms, transform 200ms, box-shadow 200ms; + + &__label { + @extend .c-label; + @extend .c-label--bracket; + display: block; + margin-bottom: 10px; + } + + &__title { + font-family: var(--c-font-display); + font-size: var(--c-fs-sub); + font-weight: 600; + line-height: var(--c-lh-sub); + margin: 0 0 6px; + color: var(--c-text); + } + + &__desc { + font-size: var(--c-fs-meta); + line-height: var(--c-lh-meta); + color: var(--c-text-meta); + margin: 0; + } + + &--linked:hover { + border-color: var(--c-accent); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } + } + + // ----- Bullet list (red dot, same scale as prose) ------------------ + .c-bullet-list { + margin: 0 0 var(--c-space-block); + padding: 0; + list-style: none; + line-height: var(--c-lh-body); + max-width: var(--c-prose-max); + + li { + position: relative; + padding-left: 18px; + margin-bottom: var(--c-space-bullet); + } + + li::before { + content: '·'; + position: absolute; + left: 0; + top: 0; + color: var(--c-accent); + font-weight: 700; + } + } + + // ----- Link -------------------------------------------------------- + a.c-link { + color: var(--c-accent); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 200ms; + + &:hover { + border-bottom-color: var(--c-accent); + } + } + + // ----- TOC nav (TL;DR style row of section links) ------------------ + .c-toc { + display: flex; + flex-wrap: wrap; + gap: var(--c-space-inline-3) var(--c-space-inline-4); + margin: var(--c-space-block) 0 0; + padding: 16px 0 0; + border-top: 1px solid var(--c-border); + max-width: var(--c-prose-max); + + &__label { + @extend .c-label; + @extend .c-label--bracket; + } + + a { + font-family: var(--c-font-mono); + font-size: var(--c-fs-caption); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--c-text-meta); + text-decoration: none; + border-bottom: 1px solid var(--c-border); + padding-bottom: 1px; + transition: color 200ms, border-color 200ms; + + &:hover { + color: var(--c-accent); + border-bottom-color: var(--c-accent); + } + } + } + + // ----- CTA -------------------------------------------------------- + .c-cta { + text-align: center; + padding: var(--c-space-section-wide) 24px; + border-top: 1px solid var(--c-border); + + &__title { + @extend .c-section; + margin-bottom: var(--c-space-block); + } + + &__actions { + display: inline-flex; + gap: var(--c-space-inline-4); + margin-bottom: var(--c-space-block); + } + + &__footnote { + @extend .c-caption; + color: var(--c-text-quiet); + } + } + + // ----- Button (one primitive, two variants) ----------------------- + .c-btn { + display: inline-block; + padding: 12px 24px; + font-family: var(--c-font-body); + font-size: var(--c-fs-meta); + font-weight: 600; + line-height: 1; + border-radius: 6px; + text-decoration: none; + transition: opacity 200ms, transform 200ms; + + &--primary { + background: var(--c-accent); + color: #fff; + + &:hover { + opacity: 0.9; + transform: translateY(-1px); + } + } + + &--secondary { + background: transparent; + color: var(--c-text); + border: 1px solid var(--c-border); + + &:hover { + border-color: var(--c-text); + } + } + } + + // ===================================================================== + // MIGRATION OVERRIDES — STRICT REDESIGN + // Activated by adding class="c-spine" to the .compare-page main element. + // Imported AFTER compare.scss in src/index.scss so these rules win on + // specificity ties (last loaded wins). + // + // This block is NOT cosmetic. It restructures: left-aligned hero with + // a results-strip card row, auto-numbered sections, display-sized H2s, + // editorial TL;DR with rules, figure framing for every table + code + // block, hairline section dividers, mono label vocabulary across the + // entire page. + // ===================================================================== + + // ---- Reusable mono label placeholder ------------------------------- + // Mono uppercase tracked text. No brackets — they were aesthetic + // decoration. The label content itself communicates the idea. + %c-bracket-label { + font-family: var(--c-font-mono); + font-size: var(--c-fs-label); + line-height: 1.4; + letter-spacing: var(--c-tracking-label); + font-weight: 500; + text-transform: uppercase; + color: var(--c-text-meta); + display: inline-block; + } + + // ---- Hero: LEFT-ALIGNED, paper-feel, asymmetric -------------------- + // Was centered + 3-card grid. New: flush-left hero, display-sized H1, + // sub-580 subtitle column, then the hero cards reflow as a results + // strip below the hero block instead of a card grid. + .compare-hero { + padding: 96px 64px 64px; + + &__inner { + max-width: 1200px; + margin: 0 auto; + } + + &__intro { + max-width: 880px; + margin: 0 0 64px; + text-align: left; + } + + &__eyebrow { + @extend %c-bracket-label; + color: var(--c-accent); + margin-bottom: 24px; + } + + // Kicker sits above the H1: a small mono accent line that + // carries query-bait keywords for organic discovery + // ("Looking for a Socket.io alternative..."). Doesn't compete with + // the display H1 for attention; reads as a pointed lead-in. + &__kicker { + font-family: var(--c-font-mono); + font-size: 13px; + line-height: 1.4; + letter-spacing: 0.04em; + color: var(--c-accent); + margin: 0 0 20px; + max-width: 640px; + } + + &__title { + font-family: var(--c-font-display); + // Push the display bigger and bolder than the soft migration step. + font-size: clamp(40px, 8vw, 88px); + line-height: 1; + letter-spacing: -0.025em; + font-weight: 700; + margin-bottom: 32px; + text-align: left; + } + + &__subtitle { + max-width: 640px; + margin: 0; + font-size: 19px; + line-height: 1.5; + color: var(--c-text-meta); + text-align: left; + + strong { + color: var(--c-text); + font-weight: 600; + } + } + + @include mediaMax($mobile) { + padding: 56px 24px 32px; + &__intro { + margin-bottom: 40px; + } + } + } + + // ---- Hero cards: REFLOWED as a results strip ---------------------- + // Was a 3-column card grid. Now: flush-left, no card chrome, three + // metric blocks separated by hairline rules. Big mono tabular numbers. + .compare-hero-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + border-top: 1px solid var(--c-border); + border-bottom: 1px solid var(--c-border); + + // Tablet and narrower: stack the cards to one column. At 768px + // the 3-column grid cramped the headline labels and the setup + // names ("Socket.io + CSR") wrapped mid-line awkwardly. A single + // column gives each card the full width and reads cleanly down + // to phones. + @include mediaMax($tablet) { + grid-template-columns: 1fr; + } + } + + .compare-hero-card { + padding: 32px 28px; + background: transparent; + border: none; + border-radius: 0; + border-right: 1px solid var(--c-border); + + &:last-child { + border-right: none; + } + + // Switch the divider to horizontal when the strip stacks to one + // column (same `$tablet` breakpoint as `.compare-hero-cards` above). + @include mediaMax($tablet) { + border-right: none; + border-bottom: 1px solid var(--c-border); + &:last-child { + border-bottom: none; + } + } + + &__label { + @extend %c-bracket-label; + margin-bottom: 20px; + display: block; + + .t-mute, + .t-tiny { + color: var(--c-text-quiet); + font-size: 11px; + letter-spacing: 0.05em; + } + } + + &__rows { + margin: 0; + padding: 0; + } + + &__row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 6px 0; + border-top: 1px solid var(--c-border-soft); + + &:first-child { + border-top: none; + } + + dt { + font-size: 14px; + font-weight: 400; + color: var(--c-text-meta); + } + + dd { + margin: 0; + // Treat the metric as data display: mono tabular, bigger. + font-family: var(--c-font-mono); + font-size: 18px; + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; + color: var(--c-text); + + &.is-worst { + color: var(--c-worst); + } + &.is-best { + color: var(--c-best); + font-weight: 600; + } + &.is-na { + color: var(--c-text-faint); + font-weight: 400; + } + } + } + + &__footnote { + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid var(--c-border-soft); + font-family: var(--c-font-body); + font-size: 12px; + line-height: 1.5; + color: var(--c-text-quiet); + } + + &__deeplink { + display: inline-block; + margin-top: 12px; + font-family: var(--c-font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--c-text-meta); + text-decoration: none; + border-bottom: 1px solid var(--c-border); + padding-bottom: 1px; + + &::after { + content: ' \2192'; + } + + &:hover { + color: var(--c-accent); + border-bottom-color: var(--c-accent); + } + } + } + + // ---- TL;DR: editorial abstract block with double-rule ------------- + // Was a centered card with border. Now: full-width band with hairline + // rule top and bottom, label-style header in mono, prose flush. + .compare-tldr { + max-width: none; + margin: 80px 0 0; + padding: 56px 64px; + background: transparent; + border: none; + border-top: 1px solid var(--c-border); + border-bottom: 1px solid var(--c-border); + border-radius: 0; + + &__title { + font-family: var(--c-font-mono); + font-size: var(--c-fs-label); + line-height: 1.4; + letter-spacing: var(--c-tracking-label); + font-weight: 500; + text-transform: uppercase; + color: var(--c-accent); + margin: 0 0 24px; + display: inline-block; + + // Brackets removed; the mono-uppercase styling alone signals + // "this is a label." + + .heading-anchor { + display: none; + } + } + + &__nav { + // No top border — the abstract band is already bounded by top/bottom + // hairlines; a third inner divider was visual noise. Spacing alone + // separates prose from the contents row. + // Explicit `border-top: none` because compare.scss still sets one; + // we override it here so the live page has only the band's outer + // hairlines. + margin-top: 40px; + padding-top: 0; + border-top: none; + + a { + font-family: var(--c-font-mono); + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--c-text-meta); + text-decoration: none; + border-bottom: 1px solid var(--c-border); + padding-bottom: 1px; + transition: color 200ms, border-color 200ms; + + &:hover { + color: var(--c-accent); + border-bottom-color: var(--c-accent); + } + } + } + + &__nav-label { + font-family: var(--c-font-mono); + font-size: var(--c-fs-label); + letter-spacing: var(--c-tracking-label); + color: var(--c-text-meta); + } + + @include mediaMax($mobile) { + padding: 40px 24px; + } + } + + // The TL;DR paragraphs (prose inside the abstract) get tightened too. + .compare-tldr .compare-prose { + max-width: 720px; + font-size: 18px; + line-height: 1.6; + margin-bottom: 24px; + } + + // ---- Auto-numbering removed ------------------------------------- + // Section badges ([ NN ]) and figure labels ([ FIG. NN ]) were + // counters — position info, not idea info. Eyebrows above each H2 + // already say what the section is about. Figures that deserve a + // label get a content-specific one via data-fig-label="..." + // (see .compare-frame[data-fig-label]::before below). + + // ---- Rubric: hairline dividers + display H2 + numbered prefix ----- + .compare-rubric { + border-bottom: 1px solid var(--c-border); + + &__section { + padding: 24px 0; + position: relative; + + // Reset the sub-counter at every new section. + counter-reset: subrubric; + + // Mobile: section provides the horizontal padding so content + // and media (both set to padding 0 below) sit 24px from the + // viewport edges instead of touching them. + @include mediaMax($tablet) { + padding: 0 24px; + } + } + + // Sub-chapter break indicator: small dot sits on the column + // divider line at the boundary between two adjacent sub-sections. + // Marks the break as deliberate instead of letting the hairline + // trail off through empty space when prose and media columns + // end at different heights. Only renders on desktop where the + // two-column layout (and the vertical divider) exists. + // `@at-root` escapes the .c-spine .compare-rubric ancestor stack + // because Sass would otherwise generate + // .compare-rubric__section + .c-spine .compare-rubric__section + // (mixing combinators) for the sibling selector. + @include mediaMin($tablet) { + @at-root .c-spine .compare-rubric__section + .compare-rubric__section::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, -50%); + width: 6px; + height: 6px; + background: var(--c-text-faint); + border-radius: 50%; + z-index: 1; + } + + // Hide the dot when either side of the boundary is a full-width + // sub-section: there's no column divider for the dot to anchor + // against, so it'd float in empty space. + @at-root { + .c-spine .compare-rubric__section + .compare-rubric__section:has(.compare-rubric__content--full)::before, + .c-spine .compare-rubric__section:has(.compare-rubric__content--full) + .compare-rubric__section::before { + display: none; + } + } + } + + &__content { + padding: 80px 64px; + background: var(--c-paper); + // Softer border (--c-border-soft #f0f0f0 vs --c-border #e8e8e8) + // so the column divider recedes when the media column ends + // before the prose column (or vice versa), instead of marching + // through empty space as an orphan line. + border-right: 1px solid var(--c-border-soft); + + @include mediaMax($tablet) { + background: transparent; + border-right: none; + padding: 32px 0; + } + } + + &__media { + padding: 80px 64px; + + // Mobile: kill horizontal padding (the __section provides it), + // tighten vertical so the media column doesn't tower over the + // prose. Also drop the sticky behavior on small screens — there's + // no two-column to keep aligned. + @include mediaMax($tablet) { + padding: 0 0 48px; + } + } + + &__media--sticky { + @include mediaMax($tablet) { + position: static; + max-height: none; + overflow: visible; + } + } + + &__eyebrow { + @extend %c-bracket-label; + color: var(--c-accent); + margin-bottom: 20px; + } + + &__title { + font-family: var(--c-font-display); + font-size: clamp(36px, 5vw, 60px); + line-height: 1.05; + letter-spacing: -0.02em; + font-weight: 700; + margin: 0 0 32px; + position: relative; + + // No auto-numbered [ NN ] badge — the eyebrow above carries the + // idea (e.g. [ BENCHMARK ], [ CLUSTER ], [ COUNTERPOINT ]) and a + // bare digit didn't add information. + + .heading-anchor { + font-size: 0.5em; + font-weight: 400; + color: var(--c-text-faint); + } + } + + &__subtitle { + // Dropped the §N.M academic numbering — it read as pretentious. + // The H3 itself is enough; major rubrics keep their [ NN ] badge. + font-family: var(--c-font-display); + font-size: clamp(20px, 2.4vw, 26px); + line-height: 1.25; + letter-spacing: -0.01em; + font-weight: 600; + margin: 48px 0 16px; + + .heading-anchor { + font-size: 0.7em; + font-weight: 400; + color: var(--c-text-faint); + } + } + + &__content { + .compare-prose, + p:not([class]) { + max-width: var(--c-prose-max); + } + + .compare-bullet-list { + max-width: var(--c-prose-max); + } + + // Full-width sections: anchor LEFT, don't center. Earlier the + // auto-margins made short URLs look "lost in the middle." Now + // every block in --full sits flush-left with a comfortable max. + &--full { + text-align: left; + + > .compare-prose, + > .compare-bullet-list, + > p:not([class]), + > ol, + > .compare-callout { + max-width: var(--c-prose-max-full); + margin-left: 0; + margin-right: 0; + } + + // Frames + tables get a wider cap but stay left-anchored. + > .compare-frame, + > .compare-bench-chart, + > .compare-arch-diagram, + > .compare-code-tabs, + > .compare-code-block, + > figure { + max-width: 880px; + margin-left: 0; + margin-right: 0; + } + } + } + } + + // ---- Bullet list: red dot, mono key for the bolded term --------- + .compare-bullet-list { + margin: 0 0 var(--c-space-block); + max-width: var(--c-prose-max); + + li::before { + content: '·'; + color: var(--c-accent); + } + + strong { + font-family: var(--c-font-body); + font-weight: 700; + color: var(--c-text); + } + } + + // ---- Prose: 580px cap in any context, calm inline code ------------ + .compare-prose { + font-size: 19px; + line-height: 1.6; + margin: 0 0 28px; + + strong { + font-weight: 600; + color: var(--c-text); + } + + a, + a.link { + color: var(--c-accent); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 200ms; + + &:hover { + border-bottom-color: var(--c-accent); + } + } + } + + // ---- Inline alongside Stem prose ------------------------- + // Martian Mono renders ~15% larger on the body than Stem at the + // same em (higher x-height for code legibility). We compensate by + // scaling inline mono down to 0.8em and nudging it +1px so it + // optically sits on the same baseline as the surrounding Stem. + // Applied globally inside the spine — wherever Stem meets mono. + //
 code blocks are excluded; their sizing lives in .compare-code-block.
+  :not(pre) > code,
+  .compare-prose code,
+  .compare-meta code,
+  .compare-callout code,
+  .compare-tldr code,
+  p code,
+  li code,
+  td code,
+  summary code {
+    font-family: var(--c-font-mono);
+    font-size: 0.8em;
+    font-weight: 500;
+    line-height: 1;
+    background: rgba(0, 0, 0, 0.045);
+    padding: 2px 6px;
+    border-radius: 4px;
+    vertical-align: 1px;
+    color: var(--c-text);
+    white-space: nowrap;
+  }
+
+  // ---- Code blocks: LIGHT, framed, with mono "figure" feel ---------
+  .compare-code-block,
+  .compare-code-tabs__panel pre,
+  .compare-rubric__media pre,
+  .compare-bench-chart__pre {
+    background: var(--c-code-bg);
+    color: var(--c-text);
+    border: 1px solid var(--c-code-border);
+    border-radius: var(--c-radius);
+    box-shadow: none;
+    padding: 28px 32px;
+    font-size: 13.5px;
+    line-height: 1.7;
+  }
+
+  // Muted syntax tokens (works for the spine class definitions AND the
+  // .c-* utilities used inside compare-page code blocks).
+  .c-key {
+    color: var(--c-code-key);
+  }
+  .c-str {
+    color: var(--c-code-str);
+  }
+  .c-com {
+    color: var(--c-code-com);
+    font-style: italic;
+  }
+  .c-err {
+    color: var(--c-code-err);
+  }
+
+  // .t-mute used as code comment — keep it readable, italicize.
+  pre .t-mute,
+  .compare-code-block .t-mute,
+  .compare-code-tabs__panel .t-mute {
+    color: var(--c-code-com);
+    font-style: italic;
+  }
+
+  // ---- Code tabs: framed unit with mono labels ---------------------
+  // The container is a CSS grid with the bar in row 1 and ALL panels
+  // stacked in row 2 (same grid cell). The grid cell sizes to the
+  // tallest panel, so switching tabs never resizes the container and
+  // the page below never jumps. Inactive panels are kept in layout but
+  // hidden via `visibility: hidden` (not `display: none`, which would
+  // collapse them and re-introduce the jump).
+  .compare-code-tabs {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    grid-template-rows: auto auto;
+    border: 1px solid var(--c-code-border);
+    border-radius: var(--c-radius);
+    overflow: hidden;
+    background: var(--c-code-bg);
+    // `min-width: 0` so this grid container can shrink below the
+    // intrinsic width of its widest 
 child. Without it, a long
+    // line of code (e.g. `await fetch('http://anycable:8080/...')`)
+    // forces the whole container wider than the viewport on mobile.
+    min-width: 0;
+
+    &__bar {
+      grid-row: 1;
+      grid-column: 1;
+      display: flex;
+      flex-wrap: wrap;
+      background: var(--c-paper);
+      border-bottom: 1px solid var(--c-code-border);
+      padding: 0;
+      min-width: 0;
+    }
+
+    &__tab {
+      flex: 0 0 auto;
+      padding: 12px 18px;
+      font-family: var(--c-font-mono);
+      font-size: 12px;
+      letter-spacing: 0.04em;
+      text-transform: uppercase;
+      color: var(--c-text-meta);
+      cursor: pointer;
+      border-right: 1px solid var(--c-border-soft);
+      transition: color 200ms, background 200ms;
+
+      &:hover {
+        color: var(--c-text);
+        background: var(--c-paper);
+      }
+    }
+
+    // All panels share the same grid cell. The tallest defines the
+    // container height; the rest sit underneath, invisible but present.
+    // `!important` is needed because compare.scss sets `display: none`
+    // and `:checked ~ panel { display: block }` — those rules would
+    // collapse the inactive panels and re-introduce the jump.
+    // `min-width: 0` allows the cell to shrink below its natural
+    // content width so the inner 
 can scroll horizontally
+    // instead of pushing the whole container off-screen.
+    &__panel {
+      display: block !important;
+      grid-row: 2;
+      grid-column: 1;
+      visibility: hidden;
+      pointer-events: none;
+      min-width: 0;
+      overflow: hidden;
+    }
+
+    &__panel pre {
+      border: none;
+      border-radius: 0;
+      margin: 0;
+      overflow-x: auto;
+      max-width: 100%;
+    }
+
+    // Active tab: underline accent in red.
+    // Active panel: visible (the rest stay invisible but in flow).
+    @for $i from 1 through 5 {
+      .compare-code-tabs__radio--#{$i}:checked
+        ~ .compare-code-tabs__bar
+        .compare-code-tabs__tab--#{$i} {
+        color: var(--c-accent);
+        background: var(--c-code-bg);
+        box-shadow: inset 0 -2px 0 0 var(--c-accent);
+      }
+
+      .compare-code-tabs__radio--#{$i}:checked
+        ~ .compare-code-tabs__panel--#{$i} {
+        visibility: visible;
+        pointer-events: auto;
+      }
+    }
+  }
+
+  // ---- Callout: minimal, label-led ---------------------------------
+  // Default margins absorb the ad-hoc "margin-top: 16px" pattern that
+  // authors were inlining. A callout following prose gets enough air;
+  // a callout starting a block does too.
+  .compare-callout {
+    background: transparent;
+    border: none;
+    border-left: 2px solid var(--c-accent);
+    padding: 8px 0 8px 20px;
+    margin: 16px 0 32px;
+    font-size: 15px;
+    line-height: 1.55;
+    color: var(--c-text-meta);
+    max-width: var(--c-prose-max);
+
+    strong {
+      color: var(--c-text);
+      font-weight: 600;
+    }
+
+    // Lists inside callouts get the same treatment as bullet lists,
+    // already at the right size for the callout body text.
+    .compare-bullet-list {
+      margin: 8px 0 0;
+
+      li {
+        margin-bottom: 8px;
+      }
+    }
+  }
+
+  // ---- Utility eyebrows: mono uppercase labels (no brackets) -------
+  .t-eyebrow {
+    font-family: var(--c-font-mono);
+    font-size: var(--c-fs-label);
+    line-height: 1.4;
+    letter-spacing: var(--c-tracking-label);
+    font-weight: 500;
+    text-transform: uppercase;
+    color: var(--c-text-meta);
+  }
+
+  // ---- Frame, chart, diagram: shared container conventions ---------
+  .compare-frame,
+  .compare-bench-chart,
+  .compare-arch-diagram {
+    max-width: 880px;
+    margin: 0 0 16px;
+    position: relative;
+  }
+
+  // ---- Architecture diagram: shared system for SVG diagrams --------
+  // Goals: one typography (Stem + Martian Mono), one color palette
+  // (accent red for the "fragile" path, dark for the "stable" path,
+  // muted grays for chrome), one frame treatment.
+  //
+  // SVG text elements use font-family directly in markup, so the spine
+  // can't override font-family on  via CSS. Authors must set
+  // font-family on each  in the SVG. Use:
+  //   font-family="Stem, Arial, sans-serif"             — for box titles + body
+  //   font-family="'Martian Mono', ui-monospace"        — for labels + annotations
+  //
+  // Color tokens used inside the SVG (kept inline for SVG portability):
+  //   fragile/embedded stroke + label:  #f64343
+  //   stable/separated stroke + label:  #1a1a1a
+  //   inner box stroke:                 #ececec
+  //   description text:                 #555
+  //   helper / arrow text:              #777
+  //   light fragile fill (optional):    #fff5f5
+  .compare-arch-diagram {
+    // Match the code block container: light #fafafa, hairline border,
+    // 8px radius, matching padding. Every right-side artifact (code,
+    // diagram, table, chart) shares this visual frame.
+    padding: 28px 32px;
+    background: var(--c-code-bg);
+    border: 1px solid var(--c-code-border);
+    border-radius: var(--c-radius);
+
+    > svg {
+      display: block;
+      max-width: 100%;
+      height: auto;
+    }
+
+    figcaption {
+      display: block;
+      margin-top: 16px;
+      padding-top: 12px;
+      border-top: 1px solid var(--c-code-border);
+      font-family: var(--c-font-mono);
+      font-size: 12px;
+      line-height: 1.5;
+      letter-spacing: 0.02em;
+      color: var(--c-text-quiet);
+    }
+  }
+
+  // ---- Frame: shared container for data tables, same treatment as code
+  // `width: 100%` is needed because the parent media column is a flex
+  // container with `align-items: center`, which would otherwise let
+  // the frame keep its content's natural width (and overflow the col).
+  // `overflow-x: auto` makes the inner table scrollable when its
+  // content needs more than the column allows. Scrollbar is forced
+  // visible (not auto-hidden) so the user has affordance.
+  // ---- Frame: shared container for data tables --------------------
+  // Frame now provides INTERNAL breathing room (24px top/sides, 20px
+  // bottom) so a headline + table presented inside read as a single
+  // framed artifact, not as a table that fills its container edge-to-
+  // edge. Matches the visual rhythm of the code-block (28/32 padding).
+  .compare-frame {
+    width: 100%;
+    background: var(--c-code-bg);
+    border: 1px solid var(--c-code-border);
+    border-radius: var(--c-radius);
+    overflow-x: auto;
+    overflow-y: hidden;
+    -webkit-overflow-scrolling: touch;
+    padding: 24px 24px 20px;
+
+    // Firefox
+    scrollbar-width: thin;
+    scrollbar-color: var(--c-text-faint) transparent;
+
+    // WebKit (Chrome, Safari) — show a slim, always-visible scrollbar.
+    &::-webkit-scrollbar {
+      height: 8px;
+      background: transparent;
+    }
+    &::-webkit-scrollbar-thumb {
+      background: var(--c-text-faint);
+      border-radius: 4px;
+    }
+    &::-webkit-scrollbar-track {
+      background: var(--c-code-border);
+    }
+  }
+
+  // ---- Data-table footnote (sits OUTSIDE the frame) ---------------
+  // Mono small. Lives below the frame as a sibling, acting as the
+  // methodology line for the figure.
+  .compare-data-table__footnote {
+    font-family: var(--c-font-mono);
+    font-size: 12px;
+    line-height: 1.55;
+    letter-spacing: 0.02em;
+    color: var(--c-text-quiet);
+    margin: 12px 0 0;
+    max-width: 880px;
+
+    strong {
+      color: var(--c-text-meta);
+      font-weight: 600;
+    }
+
+    code {
+      background: rgba(0, 0, 0, 0.04);
+      padding: 1px 5px;
+      border-radius: 3px;
+      font-size: 0.95em;
+      white-space: nowrap;
+    }
+  }
+
+  // ---- Data-table headline (sits INSIDE the frame, above the table) -
+  // Acts as the figure's title: a one-line conclusion that says what the
+  // reader should take from the numbers. Stem display, semibold, dark.
+  // Bottom hairline separates it from the table data below — same
+  // weight as the header underline so the eye reads three bands inside
+  // the frame: headline · headers · data.
+  .compare-data-table__headline {
+    font-family: var(--c-font-display);
+    font-size: 16px;
+    line-height: 1.4;
+    font-weight: 600;
+    letter-spacing: -0.01em;
+    color: var(--c-text);
+    margin: 0 0 16px;
+    padding: 0 0 16px;
+    border-bottom: 1px solid var(--c-code-border);
+
+    strong {
+      color: var(--c-text);
+      font-weight: 700;
+    }
+
+    // Inline numbers inside the headline get tabular treatment so
+    // "AnyCable holds 999,954" lines up cleanly.
+    code,
+    .t-num {
+      font-family: var(--c-font-mono);
+      font-variant-numeric: tabular-nums;
+      font-size: 0.92em;
+      background: transparent;
+      padding: 0;
+    }
+  }
+
+  // ---- Kill stray inline text-align: center on headings/content ----
+  // FAQ and try-it sections had `style="text-align: center"` baked in.
+  // The lab-notebook is left-anchored; override any inline center.
+  .compare-rubric__title[style*='center'],
+  .compare-rubric__content[style*='center'],
+  .compare-rubric__content--full {
+    text-align: left !important;
+  }
+
+  // ---- Visible markers on inline 
    prose lists ------------------ + // Global reset killed list markers; add them back as mono numbers + // in accent red so the 3-question / migration / checklist items + // have proper hierarchy. Absorbs the inline `padding-left: 24px; + // margin-top: -8px` pattern authors were adding by hand. + ol.compare-prose, + .compare-rubric__content > ol { + list-style: none; + padding-left: 0; + margin: 0 0 var(--c-space-block); + counter-reset: prose-item; + + > li { + counter-increment: prose-item; + position: relative; + padding-left: 36px; + margin-bottom: 12px; + line-height: 1.55; + + &::before { + content: counter(prose-item, decimal-leading-zero); + position: absolute; + left: 0; + top: 0.1em; + font-family: var(--c-font-mono); + font-size: 0.8em; + font-weight: 500; + letter-spacing: 0.04em; + color: var(--c-accent); + } + } + } + + // ---- Editorial moments: pull quote + big inline number ----------- + // .c-pullquote: turn a paragraph into a dramatic accented block. + // .c-bignum: wrap a number in prose to render mono tabular at display + // size, inline — for "AnyCable Pro holds [bignum]999,954[/bignum]" etc. + .c-pullquote { + margin: 32px 0; + padding: 4px 0 4px 24px; + border-left: 4px solid var(--c-accent); + font-family: var(--c-font-display); + font-size: clamp(22px, 2.4vw, 28px); + line-height: 1.3; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--c-text); + max-width: 720px; + } + + .c-bignum { + display: inline-block; + font-family: var(--c-font-mono); + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--c-text); + letter-spacing: -0.02em; + line-height: 0.9; + vertical-align: -0.05em; + + // Default size: a touch larger than surrounding prose. + font-size: 1.2em; + + &--xl { + font-size: 1.6em; + } + &--xxl { + font-size: 2.2em; + vertical-align: -0.15em; + } + + &--accent { + color: var(--c-accent); + } + &--best { + color: var(--c-best); + } + } + + // ---- Data tables: refined typographic hierarchy ---------------- + // Sits inside .compare-frame which provides bg + border + radius + + // 24px of breathing room. Table itself is transparent. + // + // Reading hierarchy inside the frame: + // ─ Headline (16px Stem 600, hairline below) + // ─ Column headers (11px mono caps, stronger hairline below) + // ─ Data rows (13.5px Stem, tabular nums, hairline between rows) + // ─ (last row's hairline is removed; frame bottom padding closes it) + // + // Column alignment: first column left (labels), others right (numbers). + // Authors can opt into center via `style="text-align: center"` on a + // specific / when the data is glyph-y (✓ / ⚠ / n/a). + .compare-data-table { + width: 100%; + background: transparent; + border: none; + border-radius: 0; + border-collapse: collapse; + font-size: 13.5px; + line-height: 1.45; + + th { + font-family: var(--c-font-mono); + font-size: 11px; + line-height: 1.35; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--c-text-meta); + font-weight: 500; + background: transparent; + border-bottom: 1px solid var(--c-code-border); + padding: 0 12px 12px; + text-align: right; + vertical-align: bottom; + // Allow headers to wrap so dense tables fit in the 50% column. + white-space: normal; + + &:first-child { + text-align: left; + padding-left: 0; + } + &:last-child { + padding-right: 0; + } + } + + td { + font-variant-numeric: tabular-nums; + border-bottom: 1px solid var(--c-code-border); + padding: 12px 12px; + color: var(--c-text); + vertical-align: baseline; + text-align: right; + + &:first-child { + text-align: left; + padding-left: 0; + color: var(--c-text); + font-weight: 500; + // Keep brand names like "Socket.io" / "AnyCable" intact on + // narrow viewports — the frame already scrolls horizontally, + // so awkward mid-word breaks ("Socke / t.io") are worse than + // a wider sticky first column. Secondary span text below + // ("default", "topics") is allowed to wrap to a second line. + @include mediaMax($mobile) { + white-space: nowrap; + + > span { + display: block; + white-space: normal; + } + } + } + &:last-child { + padding-right: 0; + } + + // Secondary text within a cell (e.g. "Socket.io default" → "default") + // sits muted on the same line as the primary label. + .t-mute, + .t-tiny { + color: var(--c-text-quiet); + } + } + + // Last row: no bottom border. Frame padding-bottom (20px) finishes + // the table cleanly without a redundant hairline near the frame edge. + tr:last-child td { + border-bottom: none; + } + + // Row tints — sit ON the existing border-bottom (no border collision). + tr.is-row-best { + background: rgba(22, 163, 74, 0.05); + } + tr.is-row-worst { + background: rgba(185, 28, 28, 0.05); + } + + td.is-best, + td.is-best strong { + color: var(--c-best); + font-weight: 600; + } + + td.is-worst, + td.is-worst strong { + color: var(--c-worst); + } + + td.is-na, + td.is-na strong { + color: var(--c-text-faint); + font-weight: 400; + } + } + + .compare-data-table__footnote { + font-family: var(--c-font-mono); + font-size: 11.5px; + letter-spacing: 0.02em; + color: var(--c-text-quiet); + margin-top: 12px; + } + + // ---- Impact cards: minimal chrome -------------------------------- + .compare-impact-card { + background: var(--c-paper); + border: 1px solid var(--c-border); + border-radius: var(--c-radius); + padding: 20px 22px; + transition: border-color 200ms, transform 200ms; + + &:hover { + border-color: var(--c-accent); + transform: translateY(-1px); + } + + &__title { + font-family: var(--c-font-display); + font-size: 17px; + font-weight: 700; + color: var(--c-text); + line-height: 1.3; + margin-bottom: 4px; + } + + &__desc { + font-size: 13.5px; + line-height: 1.45; + color: var(--c-text-meta); + } + } + + // ---- Quote card: editorial blockquote, not a card --------------- + // Was double-framed (the HTML uses both .compare-frame + .compare-quote-card + // on the same element). Stripping the container box entirely: the quote + // stands on its own with a left accent rule. Eyebrow + display-sized + // quote + byline + integrated footer, no box chrome. + .compare-quote-card { + background: transparent; + border: none; + border-radius: 0; + padding: 0 0 0 24px; + border-left: 3px solid var(--c-accent); + + &__eyebrow { + display: block; + font-family: var(--c-font-mono); + font-size: var(--c-fs-label); + letter-spacing: var(--c-tracking-label); + text-transform: uppercase; + color: var(--c-accent); + margin-bottom: 20px; + } + + &__text { + font-family: var(--c-font-display); + font-size: clamp(20px, 2vw, 24px); + font-weight: 500; + font-style: normal; + line-height: 1.35; + letter-spacing: -0.01em; + color: var(--c-text); + margin: 0 0 24px; + padding: 0; + quotes: none; + } + + &__byline { + font-size: 14px; + line-height: 1.5; + color: var(--c-text-meta); + margin-bottom: 24px; + + strong { + color: var(--c-text); + font-weight: 600; + } + } + + &__footer { + padding-top: 20px; + border-top: 1px solid var(--c-border); + font-size: 13px; + line-height: 1.6; + color: var(--c-text-quiet); + + strong { + color: var(--c-text-meta); + font-weight: 600; + } + } + } + + // When .compare-quote-card sits ON a .compare-frame element (current + // HTML has both classes on one div), the quote-card styling above + // must beat the frame's container styling. These overrides ensure + // no box-in-box visual collision. + .compare-frame.compare-quote-card { + background: transparent; + border: none; + border-left: 3px solid var(--c-accent); + border-radius: 0; + overflow: visible; + } + + // ---- Try card: light surface, mono label ------------------------- + // The eyebrow inside a try-card gets default spacing so authors + // don't need to inline `style="margin-bottom: 8px"` on it. + .compare-try-card { + background: var(--c-paper); + border: 1px solid var(--c-border); + border-radius: var(--c-radius); + + &:hover { + border-color: var(--c-accent); + } + + .t-eyebrow { + font-family: var(--c-font-mono); + color: var(--c-text-meta); + display: block; + margin-bottom: 8px; + } + } + + // ---- Bench chart: same framed container as code / diagram -------- + .compare-bench-chart { + padding: 28px 32px; + background: var(--c-code-bg); + border: 1px solid var(--c-code-border); + border-radius: var(--c-radius); + + // The ASCII chart
     inside the figure sits naked on the
    +    // surface — the figure itself IS the visual frame.
    +    &__pre {
    +      border: none;
    +      background: transparent;
    +      padding: 0;
    +    }
    +
    +    figcaption {
    +      font-family: var(--c-font-mono);
    +      font-size: 12px;
    +      letter-spacing: 0.02em;
    +      color: var(--c-text-quiet);
    +      margin-top: 16px;
    +      padding-top: 12px;
    +      border-top: 1px solid var(--c-code-border);
    +    }
    +  }
    +
    +  // ---- CTA: lab-notebook footer rather than marketing hero ---------
    +  // No `border-top` — the FAQ rubric above already provides a border-
    +  // bottom hairline. Adding our own here doubled the line at the
    +  // transition.
    +  .compare-cta {
    +    padding: 128px 64px 96px;
    +    text-align: center;
    +
    +    &__title {
    +      font-family: var(--c-font-display);
    +      font-size: clamp(32px, 5vw, 56px);
    +      line-height: 1.05;
    +      letter-spacing: -0.02em;
    +      font-weight: 700;
    +      margin-bottom: 32px;
    +    }
    +
    +    &__footnote {
    +      font-family: var(--c-font-mono);
    +      font-size: 12px;
    +      letter-spacing: 0.04em;
    +      text-transform: uppercase;
    +      color: var(--c-text-quiet);
    +    }
    +  }
    +
    +  // ---- FAQ: hairline-only accordion, anchored left -----------------
    +  // compare.scss has bordered cards centered in a 720px column with
    +  // margin auto. Lab-notebook strips the chrome: hairline between
    +  // items, full-width left-anchored stack.
    +  .compare-faq {
    +    margin: 0;
    +    max-width: 720px;
    +    gap: 0;
    +    border-top: 1px solid var(--c-border);
    +  }
    +
    +  .compare-faq__item {
    +    padding: 0;
    +    background: transparent;
    +    border: none;
    +    border-bottom: 1px solid var(--c-border);
    +    border-radius: 0;
    +
    +    // The last item's bottom border duplicates the FAQ rubric's section
    +    // divider just below; drop it so the FAQ stack ends cleanly.
    +    &:last-child {
    +      border-bottom: none;
    +    }
    +
    +    &:hover,
    +    &[open] {
    +      border-color: var(--c-border);
    +    }
    +
    +    summary {
    +      font-family: var(--c-font-display);
    +      font-size: 18px;
    +      font-weight: 600;
    +      padding: 20px 0;
    +      color: var(--c-text);
    +    }
    +  }
    +
    +  .compare-faq__answer {
    +    padding-top: 0;
    +    padding-bottom: 20px;
    +    font-size: 16px;
    +    line-height: 1.65;
    +    color: var(--c-text-meta);
    +  }
    +
    +  // ---- Heading anchor (#) hidden by default, shown on hover --------
    +  // Bumped color from --c-text-faint (#bbb, 2.2:1 contrast — fails AA)
    +  // to --c-text-quiet (#777, 4.7:1) so the # is readable when visible.
    +  .compare-rubric__title .heading-anchor,
    +  .compare-rubric__subtitle .heading-anchor,
    +  .compare-tldr__title .heading-anchor {
    +    color: var(--c-text-quiet);
    +  }
    +
    +  .compare-rubric__title:not(:hover) .heading-anchor,
    +  .compare-rubric__subtitle:not(:hover) .heading-anchor,
    +  .compare-tldr__title:not(:hover) .heading-anchor {
    +    opacity: 0;
    +  }
    +
    +  // ===================================================================
    +  // ACCESSIBILITY + USER PREFERENCES
    +  // ===================================================================
    +
    +  // ---- Focus-visible: visible keyboard focus on every interactive ---
    +  // Single accent outline + 2px offset, rendered only when navigation
    +  // comes from the keyboard (not on click). `outline` is used (not
    +  // box-shadow) so it follows border-radius and never affects layout.
    +  a:focus-visible,
    +  button:focus-visible,
    +  summary:focus-visible,
    +  label:focus-visible,
    +  input:focus-visible,
    +  [tabindex]:focus-visible {
    +    outline: 2px solid var(--c-accent);
    +    outline-offset: 2px;
    +    border-radius: 2px;
    +  }
    +
    +  // Code-tab radios are visually hidden but receive focus. Forward the
    +  // focus ring onto the matching