From 563c29554f2545fc2d39b8906ea57f56ad0d201b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:06:46 +0000 Subject: [PATCH] feat(docs): add landing page and retheme docs to match Recreate the EffectMQ landing design as the docs app homepage: hero with install command, constellation canvas animations for the task lifecycle and semaphore concurrency modes, tabbed API walkthrough, benchmark stat cards and animated bar chart, feature grid, CTA and footer. Retheme the Fumadocs UI to the same design language: the landing palette as the dark theme (default), lime accent as primary, Space Grotesk and JetBrains Mono via Google Fonts, and the effectmq wordmark in the navbar. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018GXhXDgu3krEvuANNi4GKC --- apps/docs/app/docs/[[...slug]]/page.tsx | 2 +- apps/docs/app/global.css | 64 ++ apps/docs/app/landing.css | 722 ++++++++++++++++++ apps/docs/app/layout.tsx | 22 +- apps/docs/app/page.tsx | 269 ++++++- apps/docs/components/landing/api-tabs.tsx | 27 + apps/docs/components/landing/bench-chart.tsx | 68 ++ .../docs/components/landing/code-snippets.tsx | 270 +++++++ .../components/landing/concurrency-tabs.tsx | 50 ++ .../components/landing/install-command.tsx | 34 + .../components/landing/lifecycle-canvas.tsx | 368 +++++++++ apps/docs/components/landing/sem-canvas.tsx | 381 +++++++++ apps/docs/lib/layout.shared.tsx | 9 +- 13 files changed, 2279 insertions(+), 7 deletions(-) create mode 100644 apps/docs/app/landing.css create mode 100644 apps/docs/components/landing/api-tabs.tsx create mode 100644 apps/docs/components/landing/bench-chart.tsx create mode 100644 apps/docs/components/landing/code-snippets.tsx create mode 100644 apps/docs/components/landing/concurrency-tabs.tsx create mode 100644 apps/docs/components/landing/install-command.tsx create mode 100644 apps/docs/components/landing/lifecycle-canvas.tsx create mode 100644 apps/docs/components/landing/sem-canvas.tsx diff --git a/apps/docs/app/docs/[[...slug]]/page.tsx b/apps/docs/app/docs/[[...slug]]/page.tsx index 840c88a..3fff59d 100644 --- a/apps/docs/app/docs/[[...slug]]/page.tsx +++ b/apps/docs/app/docs/[[...slug]]/page.tsx @@ -1,10 +1,10 @@ -import { createRelativeLink } from "fumadocs-ui/mdx"; import { DocsBody, DocsDescription, DocsPage, DocsTitle, } from "fumadocs-ui/layouts/docs/page"; +import { createRelativeLink } from "fumadocs-ui/mdx"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getMDXComponents } from "@/components/mdx"; diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 423e5cf..d3428a8 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -2,6 +2,70 @@ @import "fumadocs-ui/css/neutral.css"; @import "fumadocs-ui/css/preset.css"; +@theme { + --font-sans: "Space Grotesk", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; +} + +:root { + --accent: #c6f94f; + /* Light theme: same character as the landing palette, readable on white. */ + --color-fd-background: hsl(240, 6%, 97%); + --color-fd-foreground: hsl(240, 8%, 8%); + --color-fd-muted: hsl(240, 6%, 93%); + --color-fd-muted-foreground: hsl(240, 5%, 42%); + --color-fd-card: hsl(240, 6%, 95%); + --color-fd-card-foreground: hsl(240, 8%, 8%); + --color-fd-border: hsla(240, 8%, 60%, 0.35); + --color-fd-primary: hsl(78, 85%, 26%); + --color-fd-primary-foreground: hsl(240, 8%, 98%); + --color-fd-ring: hsl(78, 85%, 30%); +} + +/* Dark theme: the landing page palette. */ +.dark { + --color-fd-background: #0b0b0e; + --color-fd-foreground: #ededf2; + --color-fd-muted: #101016; + --color-fd-muted-foreground: #8b8b98; + --color-fd-popover: #0e0e13; + --color-fd-popover-foreground: #b9b9c4; + --color-fd-card: #101016; + --color-fd-card-foreground: #ededf2; + --color-fd-border: #23232b; + --color-fd-primary: var(--accent); + --color-fd-primary-foreground: #0b0b0e; + --color-fd-secondary: #101016; + --color-fd-secondary-foreground: #ededf2; + --color-fd-accent: #1a1a22; + --color-fd-accent-foreground: #ededf2; + --color-fd-ring: var(--accent); +} + +.dark #nd-sidebar { + --color-fd-muted: #101016; + --color-fd-secondary: #15151c; + --color-fd-muted-foreground: #8b8b98; +} + +.dark ::selection { + background: var(--accent); + color: #0b0b0e; +} + html { scrollbar-gutter: stable; } + +.docs-wordmark { + font: 700 16px var(--font-mono); + letter-spacing: -0.02em; +} + +.docs-wordmark-accent { + color: var(--accent); +} + +:root:not(.dark) .docs-wordmark-accent { + color: var(--color-fd-primary); +} diff --git a/apps/docs/app/landing.css b/apps/docs/app/landing.css new file mode 100644 index 0000000..6d46497 --- /dev/null +++ b/apps/docs/app/landing.css @@ -0,0 +1,722 @@ +/* Landing page styles — recreated from the EffectMQ landing design handoff. */ + +.lp { + background: #0b0b0e; + min-height: 100vh; + color: #ededf2; + font-family: "Space Grotesk", system-ui, sans-serif; +} + +.lp ::selection { + background: var(--accent); + color: #0b0b0e; +} + +.lp a { + color: var(--accent); + text-decoration: none; +} + +.lp a:hover { + text-decoration: underline; +} + +@keyframes lp-blink { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} + +.lp-container { + max-width: 1200px; + margin: 0 auto; + padding-left: 32px; + padding-right: 32px; +} + +/* ---- nav ---- */ + +.lp-nav { + padding-top: 22px; + padding-bottom: 22px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.lp-wordmark { + font: + 700 18px "JetBrains Mono", + monospace; + letter-spacing: -0.02em; + color: #ededf2; +} + +.lp-wordmark .lp-accent { + color: var(--accent); +} + +.lp-cursor { + color: var(--accent); + animation: lp-blink 1.1s step-end infinite; +} + +.lp-nav-links { + display: flex; + align-items: center; + gap: 26px; + font: + 500 14px "Space Grotesk", + sans-serif; +} + +.lp a.lp-nav-docs { + color: #b9b9c4; +} + +.lp a.lp-nav-github { + color: #0b0b0e; + background: var(--accent); + padding: 8px 16px; + border-radius: 8px; + font-weight: 600; +} + +/* ---- hero ---- */ + +.lp-hero { + position: relative; + overflow: hidden; +} + +.lp-hero-grid { + position: absolute; + inset: 0; + background-image: radial-gradient( + rgba(255, 255, 255, 0.05) 1px, + transparent 1px + ); + background-size: 28px 28px; + pointer-events: none; +} + +.lp-hero-glow { + position: absolute; + inset: 0; + background: radial-gradient( + 700px 340px at 72% -10%, + color-mix(in oklab, var(--accent) 13%, transparent), + transparent + ); + pointer-events: none; +} + +.lp-hero-inner { + position: relative; + padding-top: 88px; + padding-bottom: 72px; +} + +.lp-chips { + display: flex; + gap: 10px; + margin-bottom: 28px; + flex-wrap: wrap; +} + +.lp-chip { + font: + 500 11px "JetBrains Mono", + monospace; + letter-spacing: 0.1em; + color: #8b8b98; + border: 1px solid #26262e; + border-radius: 999px; + padding: 4px 12px; +} + +.lp-chip-accent { + color: var(--accent); + border-color: color-mix(in oklab, var(--accent) 40%, transparent); +} + +.lp-hero h1 { + margin: 0 0 26px; + font: + 700 clamp(46px, 6.4vw, 84px) / 1.02 "Space Grotesk", + sans-serif; + letter-spacing: -0.035em; + max-width: 900px; + text-wrap: balance; +} + +.lp-hero h1 .lp-accent, +.lp-h2 .lp-accent { + color: var(--accent); +} + +.lp-hero-sub { + margin: 0 0 40px; + font: + 400 19px / 1.6 "Space Grotesk", + sans-serif; + color: #a7a7b3; + max-width: 640px; + text-wrap: pretty; +} + +.lp-cta-row { + display: flex; + gap: 14px; + align-items: stretch; + flex-wrap: wrap; +} + +.lp-install { + display: flex; + align-items: center; + gap: 14px; + background: #101016; + border: 1px solid #2a2a33; + border-radius: 10px; + padding: 14px 18px; + font: + 500 14px "JetBrains Mono", + monospace; +} + +.lp-install .lp-prompt { + color: #8b8b98; + user-select: none; +} + +.lp-install .lp-pkg { + white-space: nowrap; +} + +.lp-install .lp-pkg .lp-accent { + color: var(--accent); +} + +.lp-copy-btn { + font: + 600 12px "JetBrains Mono", + monospace; + color: #0b0b0e; + background: var(--accent); + border: none; + border-radius: 6px; + padding: 7px 13px; + cursor: pointer; +} + +.lp-copy-btn:hover { + filter: brightness(1.1); +} + +.lp a.lp-ghost-btn { + display: flex; + align-items: center; + color: #ededf2; + border: 1px solid #2a2a33; + border-radius: 10px; + padding: 14px 22px; + font: + 500 15px "Space Grotesk", + sans-serif; +} + +.lp a.lp-ghost-btn:hover { + border-color: #3d3d49; + text-decoration: none; +} + +/* ---- sections ---- */ + +.lp-eyebrow { + margin: 0 0 14px; + font: + 600 12px "JetBrains Mono", + monospace; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--accent); +} + +.lp-h2 { + margin: 0 0 16px; + font: + 700 clamp(30px, 3.6vw, 46px) / 1.1 "Space Grotesk", + sans-serif; + letter-spacing: -0.02em; +} + +.lp-lede { + margin: 0 0 36px; + font: + 400 16px / 1.6 "Space Grotesk", + sans-serif; + color: #a7a7b3; + max-width: 620px; + text-wrap: pretty; +} + +.lp-band { + border-top: 1px solid #1a1a22; + background: #0d0d12; +} + +/* ---- panels / canvases ---- */ + +.lp-panel { + background: #0e0e13; + border: 1px solid #23232b; + border-radius: 14px; +} + +.lp-canvas-panel { + padding: 8px 8px 0; +} + +.lp-canvas-panel canvas, +.lp-sem-panel canvas { + width: 100%; + display: block; +} + +.lp-sem-panel { + padding: 8px; +} + +.lp-legend { + display: flex; + gap: 26px; + margin-top: 16px; + flex-wrap: wrap; + font: + 400 12px "JetBrains Mono", + monospace; + color: #8b8b98; +} + +.lp-legend span { + display: flex; + align-items: center; + gap: 8px; +} + +.lp-legend .lp-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +/* ---- code ---- */ + +.lp-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.lp-tab { + font: + 500 13px "JetBrains Mono", + monospace; + padding: 9px 16px; + border: 1px solid #2a2a33; + background: transparent; + color: #8b8b98; + border-radius: 8px; + cursor: pointer; +} + +.lp-tab[data-active="true"] { + border-color: var(--accent); + background: var(--accent); + color: #0b0b0e; +} + +.lp-tab-sm { + font-size: 12px; + padding: 7px 14px; + border-radius: 7px; +} + +.lp-code-panel { + padding: 26px 30px; + overflow: auto; +} + +.lp-code-panel pre { + margin: 0; + font: + 400 13.5px / 1.75 "JetBrains Mono", + monospace; + color: #c9c9d4; +} + +.lp-code-sm { + border-radius: 12px; + padding: 18px 20px; +} + +.lp-code-sm pre { + font-size: 12.5px; + line-height: 1.7; +} + +.lp-code-panel .tk-k { + color: #b8a7ff; +} + +.lp-code-panel .tk-t { + color: #7ee0da; +} + +.lp-code-panel .tk-s { + color: var(--accent); +} + +.lp-code-panel .tk-c { + color: #61616e; + font-style: italic; +} + +.lp-code-panel .tk-n { + color: #ffb86b; +} + +/* ---- concurrency ---- */ + +.lp-conc-grid { + padding-top: 96px; + padding-bottom: 96px; + display: grid; + grid-template-columns: minmax(320px, 5fr) 6fr; + gap: 56px; + align-items: center; +} + +.lp-conc-body { + margin: 0 0 22px; + font: + 400 16px / 1.65 "Space Grotesk", + sans-serif; + color: #a7a7b3; + text-wrap: pretty; +} + +.lp-conc-body .lp-inline-code { + font-family: "JetBrains Mono", monospace; + font-size: 14px; + color: #ededf2; +} + +.lp-conc-tabs { + margin-bottom: 12px; +} + +/* ---- benchmarks ---- */ + +.lp-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 22px; +} + +.lp-stat-card { + background: #101016; + border: 1px solid #23232b; + border-radius: 12px; + padding: 22px 24px; +} + +.lp-stat-value { + margin: 0; + font: + 700 34px "Space Grotesk", + sans-serif; + color: var(--accent); + letter-spacing: -0.02em; +} + +.lp-stat-unit { + font-size: 17px; + color: #8b8b98; + font-weight: 500; +} + +.lp-stat-caption { + margin: 6px 0 0; + font: + 400 13px "Space Grotesk", + sans-serif; + color: #8b8b98; +} + +.lp-chart-panel { + padding: 34px 34px 26px; +} + +.lp-chart-title { + margin: 0 0 26px; + font: + 500 12px "JetBrains Mono", + monospace; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #8b8b98; +} + +.lp-chart-groups { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 40px; +} + +.lp-bars { + display: flex; + align-items: flex-end; + gap: 14px; + height: 210px; +} + +.lp-bar-col { + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 100%; + gap: 8px; +} + +.lp-bar-value { + margin: 0; + text-align: center; + font: + 500 12px "JetBrains Mono", + monospace; + color: #ededf2; +} + +.lp-bar { + background: linear-gradient( + to top, + color-mix(in oklab, var(--accent) 55%, #0e0e13), + var(--accent) + ); + border-radius: 6px 6px 2px 2px; + transition: height 0.9s cubic-bezier(0.2, 0.7, 0.2, 1); + min-height: 3px; +} + +.lp-bar-labels { + display: flex; + gap: 14px; + margin-top: 10px; +} + +.lp-bar-labels p { + margin: 0; + flex: 1; + text-align: center; + font: + 400 11px "JetBrains Mono", + monospace; + color: #63636e; +} + +.lp-group-label { + margin: 14px 0 0; + text-align: center; + font: + 500 13px "JetBrains Mono", + monospace; + color: #b9b9c4; +} + +.lp-footnote { + margin: 16px 2px 0; + font: + 400 12px / 1.7 "JetBrains Mono", + monospace; + color: #63636e; +} + +/* ---- feature cards ---- */ + +.lp-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; +} + +.lp-card { + background: #101016; + border: 1px solid #23232b; + border-radius: 12px; + padding: 26px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.lp-card:hover { + border-color: #34343f; +} + +.lp-card h3 { + margin: 0; + font: + 600 19px "Space Grotesk", + sans-serif; + letter-spacing: -0.01em; +} + +.lp-card p { + margin: 0; + font: + 400 14px / 1.6 "Space Grotesk", + sans-serif; + color: #8b8b98; + flex: 1; + text-wrap: pretty; +} + +.lp-card code { + font: + 400 12px "JetBrains Mono", + monospace; + color: var(--accent); +} + +/* ---- CTA ---- */ + +.lp-cta { + border-top: 1px solid #1a1a22; + position: relative; + overflow: hidden; +} + +.lp-cta-glow { + position: absolute; + inset: 0; + background: radial-gradient( + 640px 300px at 50% 120%, + color-mix(in oklab, var(--accent) 12%, transparent), + transparent + ); + pointer-events: none; +} + +.lp-cta-inner { + position: relative; + padding-top: 110px; + padding-bottom: 120px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; +} + +.lp-cta h2 { + margin: 0; + font: + 700 clamp(34px, 4.4vw, 58px) / 1.1 "Space Grotesk", + sans-serif; + letter-spacing: -0.025em; + max-width: 760px; + text-wrap: balance; +} + +.lp-cta h2 .lp-accent { + color: var(--accent); +} + +.lp a.lp-star-link { + font: + 500 15px "Space Grotesk", + sans-serif; +} + +/* ---- footer ---- */ + +.lp-footer { + border-top: 1px solid #1a1a22; + background: #0a0a0d; +} + +.lp-footer-row { + padding-top: 48px; + padding-bottom: 28px; + display: grid; + grid-template-columns: 1fr auto; + gap: 40px; + align-items: center; +} + +.lp-footer-mark { + font: + 700 16px "JetBrains Mono", + monospace; +} + +.lp-footer-mark .lp-accent { + color: var(--accent); +} + +.lp-footer-links { + display: flex; + gap: 28px; + align-items: center; + justify-self: end; + font: + 400 14px "Space Grotesk", + sans-serif; +} + +.lp a.lp-footer-link { + color: #b9b9c4; +} + +.lp-footer-fine { + padding-bottom: 36px; +} + +.lp-footer-fine p { + margin: 0; + font: + 400 12px "JetBrains Mono", + monospace; + color: #4a4a54; +} + +.lp .lp-footer-fine a { + color: #63636e; +} + +/* ---- responsive ---- */ + +@media (max-width: 960px) { + .lp-conc-grid { + grid-template-columns: 1fr; + gap: 36px; + } + .lp-stats, + .lp-cards, + .lp-chart-groups { + grid-template-columns: 1fr; + } + .lp-chart-groups { + gap: 32px; + } +} diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index e39c2c3..d3e6d69 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -4,15 +4,31 @@ import type { ReactNode } from "react"; import "./global.css"; export const metadata: Metadata = { - title: "effectmq", - description: "Documentation for effectmq.", + title: { + template: "%s | effectmq", + default: "effectmq", + }, + description: + "A typed, Redis-backed task queue built on Effect: schemas for payloads, results and errors, with retries, delays, idempotency and cron schedules.", }; export default function RootLayout({ children }: { children: ReactNode }) { return ( + + + + + - {children} + {children} ); diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index f8c9409..92aa34f 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -1,5 +1,270 @@ -import { redirect } from "next/navigation"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { ApiTabs } from "@/components/landing/api-tabs"; +import { BenchChart } from "@/components/landing/bench-chart"; +import { ConcurrencyTabs } from "@/components/landing/concurrency-tabs"; +import { InstallCommand } from "@/components/landing/install-command"; +import { LifecycleCanvas } from "@/components/landing/lifecycle-canvas"; +import "./landing.css"; + +export const metadata: Metadata = { + title: "effectmq — typed task queue built on Effect", + description: + "A task queue built on Effect. Describe work as a schema, process it with a handler that is just an Effect. Retries, delays, idempotency, cron schedules: handled. Redis keeps unfinished work recoverable.", +}; + +const GITHUB_URL = "https://github.com/julia-script/effectmq"; + +const FEATURES = [ + { + title: "Retries are Schedules", + body: "Declare retry on the task as an Effect Schedule — exponential, jittered, whatever composes. Exhausted schedule → your failure policy.", + code: 'retry: Schedule.exponential("1 second")', + }, + { + title: "Idempotency is the id", + body: "The idempotency key is the task id. Offer the same key twice and you get one task, not two — replacement is an explicit new generation.", + // biome-ignore lint/suspicious/noTemplateCurlyInString: displayed as literal code + code: "idempotencyKey: (p) => `email:${p.to}`", + }, + { + title: "Durable cron", + body: "Scheduler.make materializes a real queue task per tick — competing schedulers and crash recovery can safely re-offer it.", + code: 'missed: { _tag: "coalesce" }', + }, + { + title: "Typed lifecycle events", + body: "task.completed carries your success type; task.failed carries your typed error. Streams, wait and execute all decode against your schemas.", + code: "TaskQueue.stream(emails)", + }, + { + title: "At-least-once, fenced", + body: "Every attempt is fenced with a unique lease token; stalled workers are separated from failed handlers. Unfinished work stays recoverable.", + code: "lease: unique token per attempt", + }, + { + title: "Swappable engine", + body: "The Redis engine is a Layer built on atomic Lua scripts. Provide it and forget it — or swap it for a different implementation, like anything in Effect.", + code: "TaskEngine.layer({ redis })", + }, +]; export default function HomePage() { - redirect("/docs"); + return ( +
+ + +
+
+
+
+
+ BUILT ON EFFECT 4 + REDIS-BACKED + MIT +
+

+ Typed payloads. +
+ Typed results. +
+ Typed errors. All the way down. +

+

+ A task queue built on Effect. + Describe work as a schema, process it with a handler that is just an + Effect. Retries, delays, idempotency, cron schedules: handled. Redis + keeps unfinished work recoverable. +

+
+ + + Read the docs → + +
+
+
+ +
+

01 — Lifecycle

+

How a task lives.

+

+ The state machine every task moves through: offered, queued, leased + under a unique fence token, and acknowledged — or routed back through + its retry Schedule. Unfinished work survives worker loss. (An + illustration of the engine's internals, not a product dashboard.) +

+
+ +
+
+ + + waiting / scheduled + + + + active (fenced attempt) + + + + retry / failed + + + + success + +
+
+ +
+

02 — The API

+

The whole loop, in thirty seconds.

+

+ A task is a schema, not a function. Your handler receives a fully + decoded payload — the real object, not a JSON string — and its + failures are pattern-matchable typed errors. +

+ +
+ +
+
+ +
+
+ +
+

04 — Receipts

+

Numbers, not vibes.

+

+ Every release ships reproducible performance evidence: full end-to-end + task lifecycles — atomic create, fenced acquire, acknowledge — + measured per payload size and concurrency. +

+
+
+

+ 2,968 tasks/s +

+

+ peak lifecycle throughput · 1 KiB @ c=32 +

+
+
+

+ 1.44 ms p50 +

+

+ single-task lifecycle latency · 1 KiB @ c=1 +

+
+
+

+ 39,097 items/s +

+

+ due-backlog sweep, atomic Lua batches +

+
+
+ +

+ baseline: Node 22 · Redis 8.0.6 (loopback) · Effect 4.0.0-beta.107 · + macOS arm64 — committed as{" "} + + regression evidence + + , not a capacity claim. +

+
+ +
+
+

05 — Batteries

+

+ Handled, so you don't. +

+
+ {FEATURES.map((f) => ( +
+

{f.title}

+

{f.body}

+ {f.code} +
+ ))} +
+
+
+ +
+
+
+

+ Put work in. Take attempts. +
+ Redis keeps the rest recoverable. +

+ + + Star on GitHub ↗ + +
+
+ +
+
+ + effectmq + +
+ + Docs + + + GitHub + + + npm + +
+
+
+

+ MIT · built on Effect · + requires the Effect 4 beta · © 2026 +

+
+
+
+ ); } diff --git a/apps/docs/components/landing/api-tabs.tsx b/apps/docs/components/landing/api-tabs.tsx new file mode 100644 index 0000000..b107ada --- /dev/null +++ b/apps/docs/components/landing/api-tabs.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useState } from "react"; +import { apiSnippets, apiTabLabels } from "./code-snippets"; + +export function ApiTabs() { + const [tab, setTab] = useState(0); + + return ( + <> +
+ {apiTabLabels.map((label, i) => ( + + ))} +
+
{apiSnippets[tab]}
+ + ); +} diff --git a/apps/docs/components/landing/bench-chart.tsx b/apps/docs/components/landing/bench-chart.tsx new file mode 100644 index 0000000..52a1142 --- /dev/null +++ b/apps/docs/components/landing/bench-chart.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const GROUPS: ReadonlyArray]> = [ + ["64 B", [592.3, 2179.9, 2922.4]], + ["1 KiB", [640.8, 2346.9, 2968.0]], + ["16 KiB", [483.4, 1623.9, 2035.6]], +]; + +export function BenchChart() { + const [chartsIn, setChartsIn] = useState(false); + const panelRef = useRef(null); + + useEffect(() => { + const el = panelRef.current; + if (!el) return; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setChartsIn(true); + io.disconnect(); + } + }, + { threshold: 0.25 }, + ); + io.observe(el); + return () => io.disconnect(); + }, []); + + return ( +
+

+ Completed lifecycles per second — by payload × concurrency +

+
+ {GROUPS.map(([label, values]) => ( +
+
+ {values.map((v, i) => ( +
+

+ {Math.round(v).toLocaleString("en-US")} +

+
+
+ ))} +
+
+

c=1

+

c=8

+

c=32

+
+

{label} payload

+
+ ))} +
+
+ ); +} diff --git a/apps/docs/components/landing/code-snippets.tsx b/apps/docs/components/landing/code-snippets.tsx new file mode 100644 index 0000000..686354d --- /dev/null +++ b/apps/docs/components/landing/code-snippets.tsx @@ -0,0 +1,270 @@ +import type { ReactNode } from "react"; + +function K({ children }: { children: ReactNode }) { + return {children}; +} + +function T({ children }: { children: ReactNode }) { + return {children}; +} + +function S({ children }: { children: ReactNode }) { + return {children}; +} + +function C({ children }: { children: ReactNode }) { + return {children}; +} + +function N({ children }: { children: ReactNode }) { + return {children}; +} + +export const apiTabLabels = [ + "01 define", + "02 queue", + "03 worker", + "04 concurrency", + "05 schedule", +]; + +export const apiSnippets: ReadonlyArray = [ + // 01 define +
+    import {"{ Schedule, Schema }"} from "effect"
+    {";\n"}
+    import {"{ Task, TaskQueue }"} from "@effectmq/core"
+    {";\n\n"}
+    class EmailRejected extends Schema
+    {".TaggedError<"}
+    EmailRejected
+    {">()(\n  "}
+    "EmailRejected"
+    {",\n  { reason: "}
+    Schema
+    {".String },\n) {}\n\n"}
+    const SendEmail = Task
+    {".make({\n  name: "}
+    "send-email"
+    {",\n  payload: { to: "}
+    Schema
+    {".String, subject: "}
+    Schema
+    {".String },\n  success: "}
+    Schema
+    {".String,\n  error: "}
+    EmailRejected
+    {",                        "}
+    {"// typed, pattern-matchable failure\n"}
+    {"  idempotencyKey: (p) => "}
+    {/* biome-ignore lint/suspicious/noTemplateCurlyInString: displayed as literal code */}
+    {"`email:${p.to}:${p.subject}`"}
+    {",\n  retry: "}
+    Schedule
+    {".exponential("}
+    "1 second"
+    {"),   "}
+    {"// retries are just Schedules\n"}
+    {"});"}
+  
, + // 02 queue +
+    {"// A queue binds a name to a task definition.\n"}
+    const emails = TaskQueue
+    {".make("}
+    "emails"
+    {", SendEmail);\n\n"}
+    {"// Enqueue a payload — idempotent, durable.\n"}
+    yield
+    {"* "}
+    TaskQueue
+    {".offer(emails, {\n  to: "}
+    "ada@example.com"
+    {",\n  subject: "}
+    "Welcome"
+    {",\n});\n\n"}
+    {"// Offer + await the outcome in one call.\n"}
+    const result = yield
+    {"* "}
+    TaskQueue
+    {".execute(emails, {\n  to: "}
+    "grace@example.com"
+    {",\n  subject: "}
+    "Welcome"
+    {",\n}); "}
+    {"// succeeds with your value — or fails with EmailRejected\n"}
+    {"\n"}
+    {"// Or await a task you already offered.\n"}
+    const task = yield
+    {"* "}
+    TaskQueue
+    {".offer(emails, payload);\n"}
+    const outcome = yield
+    {"* "}
+    TaskQueue
+    {".wait(emails, task.handle);"}
+  
, + // 03 worker +
+    {"// Take one task, run it, report the outcome.\n"}
+    yield
+    {"* "}
+    TaskQueue
+    {".complete(emails, (task) =>\n  "}
+    Effect
+    {".succeed("}
+    {/* biome-ignore lint/suspicious/noTemplateCurlyInString: displayed as literal code */}
+    {"`provider:${task.payload.to}`"}
+    {"),\n);\n\n"}
+    {"// Or name the handler once, and loop forever.\n"}
+    const
+    {" sendEmailWorker = "}
+    TaskQueue
+    {".complete(emails, handleSendEmail);\n"}
+    yield
+    {"* sendEmailWorker.pipe("}
+    Effect
+    {".repeat("}
+    Schedule
+    {".forever));\n\n"}
+    {"// The engine + its Redis layer: the only wiring you need.\n"}
+    const AppLayer = TaskEngine
+    {".layer({\n  redis: { url: "}
+    "redis://localhost:6379"
+    {" },\n});\n\nprogram.pipe("}
+    Effect
+    {".provide(AppLayer), "}
+    NodeRuntime
+    {".runMain);"}
+  
, + // 04 concurrency +
+    {"// No bespoke concurrency options. Just Effect.\n"}
+    const worker = Effect
+    {".gen("}
+    function
+    {"* () {\n  "}
+    {"// At most 5 tasks in flight at any moment.\n"}
+    {"  "}
+    const semaphore = yield
+    {"* "}
+    Semaphore
+    {".make("}
+    5
+    {");\n\n  "}
+    yield
+    {"* "}
+    Semaphore
+    {".withPermit(\n    semaphore,\n    "}
+    TaskQueue
+    {".complete(emails, handleSendEmail),\n  ).pipe(\n    "}
+    Effect
+    {".forkScoped,               "}
+    {"// each worker is its own fiber\n"}
+    {"    "}
+    Effect
+    {".repeat("}
+    Schedule
+    {".forever), "}
+    {"// ...that keeps pulling work\n"}
+    {"  );\n});"}
+  
, + // 05 schedule +
+    {"// A durable task, materialized for every cron tick.\n"}
+    const schedule = Scheduler
+    {".make({\n  name: "}
+    "nightly-report"
+    {",\n  cron: "}
+    Cron
+    {".parseUnsafe("}
+    "0 2 * * *"
+    {", "}
+    "UTC"
+    {
+      "),\n  queue: reportQueue,\n  payload: (tick) => ({ scheduledAt: tick.scheduledAt.toISOString() }),\n  missed: { _tag: "
+    }
+    "coalesce"
+    {" },      "}
+    {"// skip | coalesce | bounded backfill\n"}
+    {"});\n\n"}
+    const worker = Worker
+    {".make(reportQueue, ({ payload }) =>\n  "}
+    Effect
+    {".log("}
+    {/* biome-ignore lint/suspicious/noTemplateCurlyInString: displayed as literal code */}
+    {"`Building report for ${payload.scheduledAt}`"}
+    {"),\n);"}
+  
, +]; + +export const semTabLabels = ["bounded", "rate limit", "fan-out"]; + +export const semSnippets: ReadonlyArray = [ + // bounded +
+    {"// At most 5 tasks in flight, across any number of runners.\n"}
+    const semaphore = yield
+    {"* "}
+    Semaphore
+    {".make("}
+    5
+    {");\n\n"}
+    yield
+    {"* "}
+    Semaphore
+    {".withPermit(\n  semaphore,\n  "}
+    TaskQueue
+    {".complete(emails, handle),\n).pipe("}
+    Effect
+    {".forkScoped, "}
+    Effect
+    {".repeat("}
+    Schedule
+    {".forever));"}
+  
, + // rate limit +
+    {"// A rate limit is just a Semaphore + a Schedule.\n"}
+    const permits = yield
+    {"* "}
+    Semaphore
+    {".make("}
+    1
+    {");\n\n"}
+    yield
+    {"* "}
+    Semaphore
+    {".withPermit(\n  permits,\n  "}
+    TaskQueue
+    {".complete(emails, handle),\n).pipe(\n  "}
+    Effect
+    {".repeat("}
+    Schedule
+    {".spaced("}
+    "100 millis"
+    {")), "}
+    {"// ≤ 10 tasks/s"}
+    {"\n);"}
+  
, + // fan-out +
+    {"// Fan out: more fibers — or more processes. Same worker.\n"}
+    const worker = TaskQueue
+    {".complete(emails, handle).pipe(\n  "}
+    Effect
+    {".repeat("}
+    Schedule
+    {".forever),\n);\n\n"}
+    yield
+    {"* "}
+    Effect
+    {".all(\n  "}
+    Array
+    {".from({ length: "}
+    6
+    {" }, () => worker),\n  { concurrency: "}
+    "unbounded"
+    {" },\n);"}
+  
, +]; diff --git a/apps/docs/components/landing/concurrency-tabs.tsx b/apps/docs/components/landing/concurrency-tabs.tsx new file mode 100644 index 0000000..febd463 --- /dev/null +++ b/apps/docs/components/landing/concurrency-tabs.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import { semSnippets, semTabLabels } from "./code-snippets"; +import { SemCanvas } from "./sem-canvas"; + +export function ConcurrencyTabs() { + const [tab, setTab] = useState(0); + + return ( + <> +
+

03 — Concurrency

+

+ Bring your own concurrency. +

+

+ No builtin concurrency knobs, rate limiting or backpressure — it + doesn't need them. complete{" "} + does exactly one task; Semaphore, Schedule and fibers decide how many + run at once. None of it is our invention. All of it composes. +

+
+ {semTabLabels.map((label, i) => ( + + ))} +
+
+ {semSnippets[tab]} +
+
+
+ +
+ + ); +} diff --git a/apps/docs/components/landing/install-command.tsx b/apps/docs/components/landing/install-command.tsx new file mode 100644 index 0000000..486496b --- /dev/null +++ b/apps/docs/components/landing/install-command.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const FULL_COMMAND = + "npm install @effectmq/core effect@4.0.0-beta.107 @effect/platform-node@4.0.0-beta.107"; + +export function InstallCommand() { + const [copied, setCopied] = useState(false); + const timer = useRef | undefined>(undefined); + + useEffect(() => () => clearTimeout(timer.current), []); + + return ( +
+ $ + + npm install @effectmq/core + + +
+ ); +} diff --git a/apps/docs/components/landing/lifecycle-canvas.tsx b/apps/docs/components/landing/lifecycle-canvas.tsx new file mode 100644 index 0000000..651e7f8 --- /dev/null +++ b/apps/docs/components/landing/lifecycle-canvas.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface Dot { + node: string; + edge: Edge | null; + t: number; + hold: number; + attempt: number; + alpha: number; + delayed: boolean; + x?: number; + y?: number; +} + +interface Edge { + a: string; + b: string; + bow: number; + label: string; + warn?: boolean; +} + +const accentColor = () => + getComputedStyle(document.documentElement) + .getPropertyValue("--accent") + .trim() || "#C6F94F"; + +/** + * Constellation state-machine animation: tasks travel the real + * storage-protocol lists (offer → scheduled/queue → worker → outcome). + */ +export function LifecycleCanvas() { + const canvasRef = useRef(null); + + useEffect(() => { + const cv = canvasRef.current; + if (!cv) return; + const ctx = cv.getContext("2d"); + if (!ctx) return; + + const WORKERS = ["worker1", "worker2", "worker3"]; + const NODES: Record< + string, + { x: number; y: number; label: string; flash: number } + > = { + offer: { x: 0.06, y: 0.5, label: "offer()", flash: 0 }, + scheduled: { x: 0.36, y: 0.12, label: "scheduled", flash: 0 }, + waiting: { x: 0.34, y: 0.64, label: "queue", flash: 0 }, + worker1: { x: 0.62, y: 0.24, label: "worker · 1", flash: 0 }, + worker2: { x: 0.66, y: 0.52, label: "worker · 2", flash: 0 }, + worker3: { x: 0.6, y: 0.8, label: "worker · 3", flash: 0 }, + success: { x: 0.9, y: 0.3, label: "success", flash: 0 }, + failed: { x: 0.9, y: 0.74, label: "failed", flash: 0 }, + }; + const EDGES: Array = [ + { a: "offer", b: "waiting", bow: 0, label: "offer" }, + { a: "offer", b: "scheduled", bow: 0, label: "delay" }, + { a: "scheduled", b: "waiting", bow: 0, label: "due" }, + { a: "waiting", b: "worker1", bow: 0, label: "lease" }, + { a: "waiting", b: "worker2", bow: 0, label: "" }, + { a: "waiting", b: "worker3", bow: 0, label: "" }, + { a: "worker1", b: "success", bow: 0, label: "" }, + { a: "worker2", b: "success", bow: 0, label: "succeed" }, + { a: "worker3", b: "success", bow: 0, label: "" }, + { a: "worker1", b: "failed", bow: 0, label: "" }, + { a: "worker2", b: "failed", bow: 0, label: "exhausted" }, + { a: "worker3", b: "failed", bow: 0, label: "" }, + { a: "worker1", b: "scheduled", bow: 0, label: "retry", warn: true }, + { a: "worker2", b: "scheduled", bow: 0, label: "", warn: true }, + { a: "worker3", b: "scheduled", bow: 0, label: "", warn: true }, + ]; + const edgeOf = (a: string, b: string) => + EDGES.find((e) => e.a === a && e.b === b) ?? null; + const dots: Array = []; + const waitQ: Array = []; + const stars = Array.from({ length: 80 }, () => ({ + x: Math.random(), + y: Math.random(), + r: Math.random() * 1.1 + 0.3, + ph: Math.random() * 6.28, + tw: 0.0006 + Math.random() * 0.0012, + })); + let last = performance.now(); + let acc = 900; + let nSuccess = 0; + let nFailed = 0; + let nRetries = 0; + let raf = 0; + + const step = (now: number) => { + raf = requestAnimationFrame(step); + const dt = Math.min(64, now - last); + last = now; + const W = cv.clientWidth; + const H = cv.clientHeight; + if (!W) return; + const dpr = window.devicePixelRatio || 1; + if (cv.width !== Math.round(W * dpr)) { + cv.width = Math.round(W * dpr); + cv.height = Math.round(H * dpr); + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, W, H); + const AC = accentColor(); + const P = (n: string): [number, number] => [ + NODES[n].x * W, + NODES[n].y * H, + ]; + const bez = (e: Edge, t: number): [number, number] => { + const [x0, y0] = P(e.a); + const [x1, y1] = P(e.b); + const dx = x1 - x0; + const dy = y1 - y0; + const len = Math.hypot(dx, dy) || 1; + const cx = (x0 + x1) / 2 + (-dy / len) * e.bow * W; + const cyy = (y0 + y1) / 2 + (dx / len) * e.bow * W; + const v = 1 - t; + return [ + v * v * x0 + 2 * v * t * cx + t * t * x1, + v * v * y0 + 2 * v * t * cyy + t * t * y1, + ]; + }; + ctx.font = "10px 'JetBrains Mono', monospace"; + ctx.textAlign = "center"; + ctx.lineWidth = 1; + // background starfield (twinkling) + for (const s of stars) { + ctx.globalAlpha = + 0.14 + 0.16 * (0.5 + 0.5 * Math.sin(now * s.tw + s.ph)); + ctx.fillStyle = "#EDEDF2"; + ctx.beginPath(); + ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + // constellation lines: straight, dashed, stopping short of each star + for (const e of EDGES) { + const col = e.warn ? "rgba(255,138,92,0.32)" : "rgba(237,237,242,0.18)"; + ctx.strokeStyle = col; + ctx.setLineDash([4, 5]); + ctx.beginPath(); + const [sx, sy] = bez(e, 0.06); + ctx.moveTo(sx, sy); + const [ex, ey] = bez(e, 0.94); + ctx.lineTo(ex, ey); + ctx.stroke(); + ctx.setLineDash([]); + // small directional tick at 0.85 + const [ax, ay] = bez(e, 0.82); + const [bx2, by2] = bez(e, 0.87); + const ang = Math.atan2(by2 - ay, bx2 - ax); + ctx.fillStyle = col; + ctx.beginPath(); + ctx.moveTo(bx2, by2); + ctx.lineTo( + bx2 - 6 * Math.cos(ang - 0.5), + by2 - 6 * Math.sin(ang - 0.5), + ); + ctx.lineTo( + bx2 - 6 * Math.cos(ang + 0.5), + by2 - 6 * Math.sin(ang + 0.5), + ); + ctx.fill(); + // label + const [lx, ly] = bez(e, 0.5); + ctx.fillStyle = e.warn + ? "rgba(255,138,92,0.75)" + : "rgba(139,139,152,0.7)"; + ctx.fillText(e.label, lx, ly - 7); + } + // spawn + acc += dt; + if (acc > 900 && dots.length < 24) { + acc = 0; + NODES.offer.flash = 200; + dots.push({ + node: "offer", + edge: null, + t: 0, + hold: 220, + attempt: 0, + alpha: 1, + delayed: Math.random() < 0.2, + }); + } + // waiting -> workers dispatch (one fenced attempt per worker) + for (const w of WORKERS) { + if (!waitQ.length) break; + const busy = dots.some( + (d) => (d.node === w && !d.edge) || (d.edge && d.edge.b === w), + ); + if (!busy) { + const d = waitQ.shift(); + if (d) { + d.edge = edgeOf("waiting", w); + d.t = 0; + } + } + } + // step dots + for (let i = dots.length - 1; i >= 0; i--) { + const d = dots[i]; + if (d.edge) { + d.t += dt / (d.edge.warn ? 950 : 780); + const [x, y] = bez(d.edge, Math.min(1, d.t)); + d.x = x; + d.y = y; + if (d.t >= 1) { + d.node = d.edge.b; + d.edge = null; + NODES[d.node].flash = 220; + if (d.node === "waiting") { + waitQ.push(d); + d.hold = -1; + } else if (d.node === "scheduled") + d.hold = 900 + Math.random() * 900; + else if (d.node.indexOf("worker") === 0) + d.hold = 650 + Math.random() * 950; + else { + d.hold = 300; + if (d.node === "success") nSuccess++; + else nFailed++; + } + } + } else if (d.hold >= 0) { + d.hold -= dt; + if (d.hold < 0) { + if (d.node === "offer") { + d.edge = edgeOf("offer", d.delayed ? "scheduled" : "waiting"); + d.t = 0; + } else if (d.node === "scheduled") { + d.edge = edgeOf("scheduled", "waiting"); + d.t = 0; + } else if (d.node.indexOf("worker") === 0) { + const r = Math.random(); + if (d.attempt === 0 && r < 0.3) { + d.attempt = 1; + d.edge = edgeOf(d.node, "scheduled"); + nRetries++; + } else if (r < (d.attempt === 0 ? 0.36 : 0.15)) + d.edge = edgeOf(d.node, "failed"); + else d.edge = edgeOf(d.node, "success"); + d.t = 0; + } else if (d.node === "success" || d.node === "failed") { + d.alpha -= dt * 0.002; + d.hold = 0; + if (d.alpha <= 0) { + dots.splice(i, 1); + } + } + } + } + } + // resting dots orbit their star like satellites + const rest: Record = {}; + for (const d of dots) { + if (d.edge) continue; + const n = d.node; + rest[n] = rest[n] || 0; + const idx = rest[n]++; + const [nx, ny] = P(n); + const ring = 17 + 7 * Math.floor(idx / 7); + const ang = idx * 2.4 + now * 0.00035; + d.x = nx + ring * Math.cos(ang); + d.y = ny + ring * Math.sin(ang); + } + // stars (nodes) + for (const k in NODES) { + const n = NODES[k]; + n.flash = Math.max(0, n.flash - dt); + const [x, y] = P(k); + const isWorker = k.indexOf("worker") === 0; + const base = + k === "success" + ? AC + : k === "failed" + ? "#FF8A5C" + : k === "scheduled" + ? "#B8A7FF" + : "#EDEDF2"; + const col = n.flash > 0 ? AC : base; + // sparkle cross + ctx.strokeStyle = col; + ctx.globalAlpha = 0.35; + ctx.beginPath(); + ctx.moveTo(x - 9, y); + ctx.lineTo(x + 9, y); + ctx.moveTo(x, y - 9); + ctx.lineTo(x, y + 9); + ctx.stroke(); + ctx.globalAlpha = 1; + // core + ctx.shadowColor = col; + ctx.shadowBlur = 14; + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(x, y, isWorker ? 4.5 : 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + // arrival pulse ring + if (n.flash > 0) { + const u = 1 - n.flash / 220; + ctx.strokeStyle = AC; + ctx.globalAlpha = 0.5 * (1 - u); + ctx.beginPath(); + ctx.arc(x, y, 8 + u * 16, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + } + // label + ctx.font = "11px 'JetBrains Mono', monospace"; + ctx.fillStyle = n.flash > 0 ? AC : "rgba(185,185,196,0.85)"; + const count = + k === "success" + ? ` · ${nSuccess}` + : k === "failed" + ? ` · ${nFailed}` + : ""; + const ly = + k === "scheduled" || k === "success" || k === "worker1" + ? y - 22 + : y + 32; + ctx.fillText(n.label + count, x, ly); + ctx.font = "10px 'JetBrains Mono', monospace"; + } + // dots on top + for (const d of dots) { + const inActive = d.node.indexOf("worker") === 0 && !d.edge; + const col = + d.edge?.warn || (d.attempt > 0 && d.node === "scheduled" && !d.edge) + ? "#FF8A5C" + : d.node === "success" || d.edge?.b === "success" + ? AC + : d.node === "failed" || d.edge?.b === "failed" + ? "#FF8A5C" + : inActive + ? "#EDEDF2" + : "#8A8A96"; + ctx.globalAlpha = Math.max(0, d.alpha); + ctx.shadowColor = col; + ctx.shadowBlur = inActive || d.node === "success" ? 9 : 0; + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(d.x ?? 0, d.y ?? 0, 4.5, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + if (inActive) { + ctx.strokeStyle = "rgba(237,237,242,0.35)"; + ctx.beginPath(); + ctx.arc(d.x ?? 0, d.y ?? 0, 8, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.globalAlpha = 1; + } + // counters + ctx.textAlign = "right"; + ctx.fillStyle = "rgba(139,139,152,0.9)"; + ctx.fillText(`retries · ${nRetries}`, W - 16, 22); + ctx.textAlign = "center"; + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, []); + + return ; +} diff --git a/apps/docs/components/landing/sem-canvas.tsx b/apps/docs/components/landing/sem-canvas.tsx new file mode 100644 index 0000000..14b45c9 --- /dev/null +++ b/apps/docs/components/landing/sem-canvas.tsx @@ -0,0 +1,381 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface Dot { + node: string; + edge: Edge | null; + t: number; + hold: number; + alpha: number; + x?: number; + y?: number; +} + +interface Edge { + a: string; + b: string; + label: string; +} + +const accentColor = () => + getComputedStyle(document.documentElement) + .getPropertyValue("--accent") + .trim() || "#C6F94F"; + +/** + * Semaphore constellation: runners offer into a queue whose head is pulled + * through a column of permit stars. Three modes — bounded (5 permits), + * rate limit (1 permit on a clock), fan-out (6 permits). + */ +export function SemCanvas({ mode }: { readonly mode: number }) { + const canvasRef = useRef(null); + const modeRef = useRef(mode); + modeRef.current = mode; + + useEffect(() => { + const cv = canvasRef.current; + if (!cv) return; + const ctx = cv.getContext("2d"); + if (!ctx) return; + + let NODES: Record< + string, + { x: number; y: number; label: string; flash: number } + > = {}; + let EDGES: Array = []; + let PERMITS: Array = []; + let currentMode = -1; + let cooldown = 0; + let rateMs = 0; + let gateLabel = ""; + let dots: Array = []; + let waitQ: Array = []; + let doneCount = 0; + const edgeOf = (a: string, b: string) => + EDGES.find((e) => e.a === a && e.b === b) ?? null; + const build = (m: number) => { + currentMode = m; + dots = []; + waitQ = []; + doneCount = 0; + cooldown = 0; + const n = m === 0 ? 5 : m === 1 ? 1 : 6; + rateMs = m === 1 ? 620 : 0; + gateLabel = + m === 0 + ? "Semaphore.make(5)" + : m === 1 + ? 'Schedule.spaced("100 millis")' + : "6 fibers · Effect.all"; + NODES = { + runnerA: { x: 0.09, y: 0.28, label: "runner A", flash: 0 }, + runnerB: { x: 0.09, y: 0.72, label: "runner B", flash: 0 }, + queue: { x: 0.38, y: 0.5, label: "queue", flash: 0 }, + done: { x: 0.92, y: 0.5, label: "done", flash: 0 }, + }; + PERMITS = []; + for (let j = 0; j < n; j++) { + const k = `permit${j}`; + PERMITS.push(k); + NODES[k] = { + x: 0.68, + y: n === 1 ? 0.5 : 0.14 + (0.72 * j) / (n - 1), + label: "", + flash: 0, + }; + } + EDGES = [ + { a: "runnerA", b: "queue", label: "offer()" }, + { a: "runnerB", b: "queue", label: "" }, + ]; + const midIdx = Math.floor(n / 2); + PERMITS.forEach((k, j) => { + EDGES.push({ + a: "queue", + b: k, + label: j === midIdx ? (m === 2 ? "complete" : "withPermit") : "", + }); + EDGES.push({ a: k, b: "done", label: "" }); + }); + }; + build(modeRef.current); + const stars = Array.from({ length: 70 }, () => ({ + x: Math.random(), + y: Math.random(), + r: Math.random() * 1.1 + 0.3, + ph: Math.random() * 6.28, + tw: 0.0006 + Math.random() * 0.0012, + })); + const runners = [ + { node: "runnerA", acc: 0, next: 700 }, + { node: "runnerB", acc: 420, next: 1100 }, + ]; + let last = performance.now(); + let raf = 0; + + const step = (now: number) => { + raf = requestAnimationFrame(step); + if (modeRef.current !== currentMode) build(modeRef.current); + const dt = Math.min(64, now - last); + last = now; + const W = cv.clientWidth; + const H = cv.clientHeight; + if (!W) return; + const dpr = window.devicePixelRatio || 1; + if (cv.width !== Math.round(W * dpr)) { + cv.width = Math.round(W * dpr); + cv.height = Math.round(H * dpr); + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, W, H); + const AC = accentColor(); + const P = (n: string): [number, number] => [ + NODES[n].x * W, + NODES[n].y * H, + ]; + const lin = (e: Edge, t: number): [number, number] => { + const [x0, y0] = P(e.a); + const [x1, y1] = P(e.b); + return [x0 + (x1 - x0) * t, y0 + (y1 - y0) * t]; + }; + ctx.font = "10px 'JetBrains Mono', monospace"; + ctx.textAlign = "center"; + ctx.lineWidth = 1; + // starfield + for (const s of stars) { + ctx.globalAlpha = + 0.14 + 0.16 * (0.5 + 0.5 * Math.sin(now * s.tw + s.ph)); + ctx.fillStyle = "#EDEDF2"; + ctx.beginPath(); + ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + // dashed constellation lines + for (const e of EDGES) { + const col = "rgba(237,237,242,0.18)"; + ctx.strokeStyle = col; + ctx.setLineDash([4, 5]); + ctx.beginPath(); + const [sx, sy] = lin(e, 0.08); + ctx.moveTo(sx, sy); + const [ex, ey] = lin(e, 0.92); + ctx.lineTo(ex, ey); + ctx.stroke(); + ctx.setLineDash([]); + const [ax, ay] = lin(e, 0.8); + const [bx2, by2] = lin(e, 0.86); + const ang = Math.atan2(by2 - ay, bx2 - ax); + ctx.fillStyle = col; + ctx.beginPath(); + ctx.moveTo(bx2, by2); + ctx.lineTo( + bx2 - 6 * Math.cos(ang - 0.5), + by2 - 6 * Math.sin(ang - 0.5), + ); + ctx.lineTo( + bx2 - 6 * Math.cos(ang + 0.5), + by2 - 6 * Math.sin(ang + 0.5), + ); + ctx.fill(); + if (e.label) { + const [lx, ly] = lin(e, 0.5); + ctx.fillStyle = "rgba(139,139,152,0.7)"; + ctx.fillText(e.label, lx, ly - 7); + } + } + // runners emit + const backlog = + waitQ.length + + dots.filter((d) => d.edge && d.edge.b === "queue").length; + for (const r of runners) { + r.acc += dt; + if (r.acc > r.next && backlog < 9) { + r.acc = 0; + r.next = 600 + Math.random() * 900; + NODES[r.node].flash = 220; + dots.push({ + node: r.node, + edge: edgeOf(r.node, "queue"), + t: 0, + hold: 0, + alpha: 1, + }); + } + } + // free permits pull from queue head (rate-limited mode releases on a clock) + const isBusy = (pk: string) => + dots.some( + (d) => (d.node === pk && !d.edge) || (d.edge && d.edge.b === pk), + ); + if (rateMs) { + cooldown -= dt; + if (cooldown <= 0 && waitQ.length) { + const pk = PERMITS.find((k) => !isBusy(k)); + if (pk) { + const d = waitQ.shift(); + if (d) { + d.edge = edgeOf("queue", pk); + d.t = 0; + cooldown = rateMs; + NODES[pk].flash = 220; + } + } + } + } else { + for (const pk of PERMITS) { + if (!waitQ.length) break; + if (!isBusy(pk)) { + const d = waitQ.shift(); + if (d) { + d.edge = edgeOf("queue", pk); + d.t = 0; + } + } + } + } + // step dots + for (let i = dots.length - 1; i >= 0; i--) { + const d = dots[i]; + if (d.edge) { + d.t += dt / 720; + const [x, y] = lin(d.edge, Math.min(1, d.t)); + d.x = x; + d.y = y; + if (d.t >= 1) { + d.node = d.edge.b; + d.edge = null; + NODES[d.node].flash = 220; + if (d.node === "queue") { + waitQ.push(d); + d.hold = -1; + } else if (d.node === "done") { + doneCount++; + d.hold = 0; + } else + d.hold = + currentMode === 1 + ? 320 + : currentMode === 2 + ? 400 + Math.random() * 800 + : 600 + Math.random() * 1400; + } + } else if (d.node === "done") { + d.alpha -= dt * 0.002; + if (d.alpha <= 0) { + dots.splice(i, 1); + } + } else if (d.hold >= 0) { + d.hold -= dt; + if (d.hold < 0 && d.node.indexOf("permit") === 0) { + d.edge = edgeOf(d.node, "done"); + d.t = 0; + } + } + } + // resting dots orbit their star + const rest: Record = {}; + for (const d of dots) { + if (d.edge) continue; + rest[d.node] = rest[d.node] || 0; + const idx = rest[d.node]++; + const [nx, ny] = P(d.node); + const ring = 15 + 7 * Math.floor(idx / 7); + const ang = idx * 2.4 + now * 0.00035; + d.x = nx + ring * Math.cos(ang); + d.y = ny + ring * Math.sin(ang); + } + // stars + for (const k in NODES) { + const n = NODES[k]; + n.flash = Math.max(0, n.flash - dt); + const [x, y] = P(k); + const isPermit = k.indexOf("permit") === 0; + const busy = isPermit && dots.some((d) => d.node === k && !d.edge); + const base = + k === "done" + ? AC + : busy + ? "#EDEDF2" + : isPermit + ? "rgba(184,167,255,0.9)" + : "#EDEDF2"; + const col = n.flash > 0 ? AC : base; + ctx.strokeStyle = col; + ctx.globalAlpha = 0.35; + ctx.beginPath(); + ctx.moveTo(x - 8, y); + ctx.lineTo(x + 8, y); + ctx.moveTo(x, y - 8); + ctx.lineTo(x, y + 8); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.shadowColor = col; + ctx.shadowBlur = 14; + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(x, y, isPermit ? 3 : 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + if (busy) { + ctx.strokeStyle = AC; + ctx.globalAlpha = 0.6; + ctx.beginPath(); + ctx.arc(x, y, 8, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + } + if (n.flash > 0) { + const u = 1 - n.flash / 220; + ctx.strokeStyle = AC; + ctx.globalAlpha = 0.5 * (1 - u); + ctx.beginPath(); + ctx.arc(x, y, 8 + u * 16, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + } + if (n.label) { + ctx.font = "11px 'JetBrains Mono', monospace"; + ctx.fillStyle = n.flash > 0 ? AC : "rgba(185,185,196,0.85)"; + const count = k === "done" ? ` · ${doneCount}` : ""; + ctx.fillText(n.label + count, x, y + 30); + ctx.font = "10px 'JetBrains Mono', monospace"; + } + } + // group label for the permit column + ctx.font = "11px 'JetBrains Mono', monospace"; + ctx.fillStyle = "rgba(185,185,196,0.85)"; + const p0 = NODES[PERMITS[0]]; + ctx.fillText( + gateLabel, + p0.x * W, + p0.y * H - (PERMITS.length === 1 ? 28 : 22), + ); + ctx.font = "10px 'JetBrains Mono', monospace"; + // traveling + resting dots + for (const d of dots) { + const atPermit = d.node.indexOf("permit") === 0 && !d.edge; + const col = + d.node === "done" || d.edge?.b === "done" + ? AC + : atPermit + ? "#EDEDF2" + : "#8A8A96"; + ctx.globalAlpha = Math.max(0, d.alpha); + ctx.shadowColor = col; + ctx.shadowBlur = atPermit || d.node === "done" ? 9 : 0; + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(d.x ?? 0, d.y ?? 0, 4.5, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + } + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, []); + + return ; +} diff --git a/apps/docs/lib/layout.shared.tsx b/apps/docs/lib/layout.shared.tsx index dd933e1..559998e 100644 --- a/apps/docs/lib/layout.shared.tsx +++ b/apps/docs/lib/layout.shared.tsx @@ -3,7 +3,14 @@ import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; export function baseOptions(): BaseLayoutProps { return { nav: { - title: "effectmq", + title: ( + + effect + mq + + ), + url: "/", }, + githubUrl: "https://github.com/julia-script/effectmq", }; }