Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Base URL for the clapping API (no trailing slash)
# Used by the ClapButton component to GET counts and POST claps via sendBeacon
PUBLIC_API_URL="http://localhost:3000"
4 changes: 4 additions & 0 deletions astro.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import starlightLlmsTxt from "starlight-llms-txt";
import { remarkClapButtons } from "./src/plugins/remark-clap-buttons";

const site = "https://agentic-engineering.swmansion.com/";
const repo = `https://github.com/software-mansion/agentic-engineering/`;
Expand Down Expand Up @@ -103,4 +104,7 @@ export default defineConfig({
plugins: [starlightLlmsTxt()],
}),
],
markdown: {
remarkPlugins: [remarkClapButtons],
},
});
258 changes: 258 additions & 0 deletions src/components/ClapButton.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
---
export interface Props {
slug: string;
}

const { slug } = Astro.props;
---

<div class="clap-wrapper" data-slug={slug}>
<div class="clap-ring">
<button
class="clap-button"
aria-label="Clap for this content"
aria-pressed="false"
>
<span class="clap-icon" aria-hidden="true">👏</span>
<span class="clap-plus" aria-hidden="true">+1</span>
</button>
</div>

<span class="clap-count" aria-live="polite">…</span>
</div>

<script>
import { z } from "astro/zod";

const ClapsResponse = z.object({
count: z.number().int().nonnegative(),
max_claps: z.number().int().positive(),
user_claps: z.number().int().nonnegative(),
});

const MAX_CLAPS = 16 as const;
const API = import.meta.env.PUBLIC_API_URL;

document
.querySelectorAll<HTMLDivElement>(".clap-wrapper")
.forEach((wrapperElement) => {
const slug = wrapperElement.dataset.slug!;
const buttonElement =
wrapperElement.querySelector<HTMLButtonElement>(".clap-button")!;
const countElement =
wrapperElement.querySelector<HTMLElement>(".clap-count")!;

const local = { claps: 0, wasSent: false };
let remote: z.infer<typeof ClapsResponse> = {
count: 0,
max_claps: MAX_CLAPS,
user_claps: 0,
};

if (!API) {
return;
}

const clapsUrl = new URL("/claps", API);
clapsUrl.searchParams.set("slug", slug);

void fetch(clapsUrl)
.then((response) => response.json())
.then((data: unknown) => {
const parsing = ClapsResponse.safeParse(data);

if (!parsing.success) {
console.error("[ClapButton] Parsing failed", parsing.error);
return;
}

remote = parsing.data;

render();
Comment on lines +69 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve optimistic count when fetch completes

If a user clicks before the initial GET /claps finishes, the click handler increments remote.count optimistically, but then remote = parsing.data replaces it with the stale server value and render() rolls the visible count backward until unload. This creates inconsistent feedback exactly on slow networks; merge the fetched payload with unsent local.claps (or reapply the local delta after assignment) before rendering.

Useful? React with 👍 / 👎.

})
.catch((fetchError) => {
console.error("[ClapButton] Fetch failed", fetchError);
});

function render() {
const localClaps = local.claps + remote.user_claps;

const total = String(remote.count);
const progress = String(localClaps / remote.max_claps);

const wasPressed = localClaps > 0;
const isDisabled = localClaps >= remote.max_claps;

countElement.textContent = total;
wrapperElement.style.setProperty("--clap-progress", progress);
buttonElement.setAttribute("aria-pressed", String(wasPressed));
buttonElement.toggleAttribute("disabled", isDisabled);
}

function animateClap() {
buttonElement.classList.remove("clap-pop");
// Reading offsetWidth forces a reflow, resetting the animation so it replays on every click.
void buttonElement.offsetWidth;
buttonElement.classList.add("clap-pop");
}

buttonElement.addEventListener("click", () => {
if (remote.user_claps + local.claps >= remote.max_claps) {
return;
}

local.claps++;
// Optimistic UI Update — keeps the displayed count in sync without a round-trip.
remote.count++;

animateClap();
render();
});

function sendClaps() {
const hasClapsToSend = local.claps > 0;
const canSend = hasClapsToSend && !local.wasSent;

if (!canSend) {
return;
}

local.wasSent = true;
window.removeEventListener("pagehide", sendClaps);

const body = JSON.stringify({ claps: local.claps });
navigator.sendBeacon(clapsUrl, body);
}

window.addEventListener("pagehide", sendClaps);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Flush pending claps on visibility change

The component only hooks sendClaps to pagehide, which is not reliably fired in common mobile/background termination flows, so local.claps can be lost without ever being sent and your clap totals/analytics drift downward. Add a visibilitychange handler that sends when document.visibilityState === "hidden" (keeping pagehide as fallback) so pending claps are flushed in lifecycle paths where pagehide never runs.

Useful? React with 👍 / 👎.

});
</script>

<style>
@layer starlight.core {
.clap-wrapper {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}

.clap-ring {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
padding: 3px;
background: conic-gradient(
var(--sl-color-text-accent) calc(var(--clap-progress, 0) * 360deg),
var(--sl-color-hairline) 0deg
);
transition: background 0.2s;
}

.clap-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
border: none;
background: var(--sl-color-bg);
cursor: pointer;
overflow: visible;
transition: background 0.15s;

&:hover:not(:disabled) {
background: color-mix(
in srgb,
var(--sl-color-bg),
var(--sl-color-text-accent) 8%
);
}

&:disabled {
cursor: not-allowed;
}

&:not(:disabled) .clap-icon {
filter: grayscale(0);
}

&.clap-pop .clap-icon {
animation: clap-pop 0.45s cubic-bezier(0.36, 0.07, 0.19, 0.97) forwards;
}

&.clap-pop .clap-plus {
animation: clap-plus-float 0.55s ease-out forwards;
}
}

.clap-icon {
font-size: 1.4rem;
line-height: 1;
display: block;
filter: grayscale(0.4);
transition: filter 0.2s;
}

.clap-plus {
position: absolute;
top: -0.25rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.7rem;
font-weight: 700;
color: var(--sl-color-text-accent);
pointer-events: none;
opacity: 0;
white-space: nowrap;
}

.clap-count {
font-size: var(--sl-text-sm);
color: var(--sl-color-gray-3);
min-width: 2ch;
text-align: center;
font-variant-numeric: tabular-nums;
}

@keyframes clap-pop {
0% {
transform: scale(1) rotate(0deg);
}

25% {
transform: scale(1.35) rotate(-14deg);
}

55% {
transform: scale(0.9) rotate(5deg);
}

75% {
transform: scale(1.08) rotate(-3deg);
}

100% {
transform: scale(1) rotate(0deg);
}
}

@keyframes clap-plus-float {
0% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}

20% {
opacity: 1;
}

100% {
opacity: 0;
transform: translateX(-50%) translateY(-1.75rem);
}
}
}
</style>
96 changes: 96 additions & 0 deletions src/plugins/remark-clap-buttons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Root, Heading } from "mdast";
import { toString } from "mdast-util-to-string";
import GithubSlugger from "github-slugger";
import path from "node:path";
import { type VFile } from "vfile";

const DOCS = path.resolve(process.cwd(), "src/content/docs");
const COMPONENTS = path.resolve(process.cwd(), "src/components");

export function remarkClapButtons(headingDepth = 2) {
return (tree: Root, vfile: VFile) => {
const filePath = vfile.history.at(0) ?? "";
const fileDirectory = path.dirname(filePath);

const relative = path.relative(DOCS, filePath);
const articleSlug = relative.replace(/\.mdx?$/, "");

const importPath = path
.relative(fileDirectory, path.join(COMPONENTS, "ClapButton.astro"))
.split(path.sep)
.join("/");

const slugger = new GithubSlugger();
const headings: { index: number; slug: string }[] = [];

for (const [index, node] of tree.children.entries()) {
const isHeading = () => node.type === "heading";
const isSpecifiedDepth = () => (node as Heading).depth === headingDepth;

if (!isHeading() || !isSpecifiedDepth()) {
continue;
}

const text = toString(node);
const sectionSlug = slugger.slug(text);
const slug = `${articleSlug}/${sectionSlug}`;

headings.push({ index, slug });
}

if (headings.length === 0) {
return;
}

for (let i = headings.length - 1; i >= 0; i--) {
const heading = headings.at(i + 1);
const at = heading ? heading.index : tree.children.length;

tree.children.splice(
at,
0,
makeClapButtonNode(headings[i].slug) as never,
);
}

tree.children.unshift(makeImportNode(importPath) as never);
};
}

function makeClapButtonNode(slug: string) {
return {
type: "mdxJsxFlowElement",
name: "ClapButton",
attributes: [{ type: "mdxJsxAttribute", name: "slug", value: slug }],
children: [],
};
}

function makeImportNode(importPath: string) {
return {
type: "mdxjsEsm",
value: `import ClapButton from "${importPath}"`,
data: {
estree: {
type: "Program",
sourceType: "module",
body: [
{
type: "ImportDeclaration",
specifiers: [
{
type: "ImportDefaultSpecifier",
local: { type: "Identifier", name: "ClapButton" },
},
],
source: {
type: "Literal",
value: importPath,
raw: JSON.stringify(importPath),
},
},
],
},
},
};
}