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 ↗ + +
+
+ + +
+ ); } 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", }; }