Skip to content
Merged
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Every release corresponds to a `staging` to `main` pull request and a matching
`vX.Y.Z` tag on `main`.

## [0.4.0] - 2026-07-17

### Added

- New thread: **"Cron was easy because the job was dumb"** β€” a deep, practical
guide to Claude routines (cron jobs whose payload is an agent). Covers routine
anatomy, a gallery of real routines with schedules and prompts, idempotency
patterns, a cron syntax refresher, routines vs. loops, and observability.
Includes a companion video link and a new `claude` tag.
- Hand-drawn **pen annotations** for thread prose (`Underline`, `Circle`, `Box`,
`Strike`, `Highlight`) that draw themselves on scroll and are gated behind
`prefers-reduced-motion`. Wired into MDX, so any thread can use them.
- **"On this page"** table of contents: a sticky right rail with scroll-spy that
highlights the current section and jumps to any heading.
- **Cron diagram** component: a styled five-field breakdown replacing brittle
ASCII art.
- **Copy to clipboard** buttons on thread code blocks.

## [0.3.0] - 2026-07-12

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "binarysemaphore",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
9 changes: 9 additions & 0 deletions src/app/threads/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { threadCovers } from "@/lib/thread-covers";
import { Photo } from "@/components/photo";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { TableOfContents } from "@/components/table-of-contents";

type Params = { slug: string };

Expand Down Expand Up @@ -67,6 +68,7 @@ export default async function ThreadPage({
<Header />

<main className="mx-auto w-full max-w-3xl flex-1 px-6 pb-20">
<div className="relative">
<nav className="pt-10 pb-8 text-sm" aria-label="Breadcrumb">
<Link
href="/threads"
Expand Down Expand Up @@ -154,6 +156,13 @@ export default async function ThreadPage({
<span aria-hidden="true">&larr;</span> All threads
</Link>
</div>

<aside className="absolute left-full top-0 hidden h-full pl-10 min-[1300px]:block">
<div className="sticky top-24 w-52">
<TableOfContents />
</div>
</aside>
</div>
</main>

<Footer />
Expand Down
189 changes: 189 additions & 0 deletions src/components/annotate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"use client";

/**
* Hand-drawn "pen" annotations for long-form thread prose. Wrap any inline text
* to circle / underline / box / strike / highlight it, the way you'd mark up a
* printed page. Each mark draws itself when it scrolls into view (respecting
* `prefers-reduced-motion`, which shows it instantly).
*
* Wired into MDX in `src/mdx-components.tsx`, so threads can write:
* Cron was <Underline>easy</Underline> because the job was <Circle>dumb</Circle>.
*
* Stroke colour comes from `currentColor`; set it with a text utility via
* `className` (default is the brand accent, i.e. a red-pen look).
*/

import { useEffect, useRef, useState, type ReactNode } from "react";

type MarkProps = {
children: ReactNode;
/** Tailwind colour utility for the stroke, e.g. "text-accent-strong". */
className?: string;
};

/** Reveal once, when the element scrolls into view. Reduced-motion => instant. */
function useDrawn<T extends Element>() {
const ref = useRef<T>(null);
const [drawn, setDrawn] = useState(false);

useEffect(() => {
const el = ref.current;
if (!el) return;

const reduce =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
const raf = requestAnimationFrame(() => setDrawn(true));
return () => cancelAnimationFrame(raf);
}

const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setDrawn(true);
io.disconnect();
}
}
},
{ threshold: 0.85, rootMargin: "0px 0px -8% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);

return { ref, drawn };
}

/** Common style for a self-drawing stroke path. */
function strokeStyle(drawn: boolean, ms: number) {
return {
strokeDasharray: 1,
strokeDashoffset: drawn ? 0 : 1,
transition: `stroke-dashoffset ${ms}ms cubic-bezier(0.65, 0, 0.35, 1)`,
} as const;
}

/** Slightly wobbly underline that sweeps in under the text. */
export function Underline({ children, className = "text-blue/80" }: MarkProps) {
const { ref, drawn } = useDrawn<HTMLSpanElement>();
return (
<span ref={ref} className="relative inline-block">
{children}
<svg
className={`pointer-events-none absolute -bottom-1 left-0 h-[0.5em] w-full overflow-visible ${className}`}
viewBox="0 0 300 12"
fill="none"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d="M3 8C58 3 118 10 178 6c40-3 80-1 119 2"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
pathLength={1}
style={strokeStyle(drawn, 1150)}
/>
</svg>
</span>
);
}

/** Open, hand-drawn ellipse looped around the text. */
export function Circle({ children, className = "text-violet/80" }: MarkProps) {
const { ref, drawn } = useDrawn<HTMLSpanElement>();
return (
<span ref={ref} className="relative inline-block px-[0.35em] py-[0.15em]">
{children}
<svg
className={`pointer-events-none absolute inset-0 h-full w-full overflow-visible ${className}`}
viewBox="0 0 300 120"
fill="none"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d="M155 9C82 6 16 28 13 60c-3 33 70 53 142 51 78-2 135-26 132-55C296 30 224 12 150 11"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
pathLength={1}
style={strokeStyle(drawn, 1600)}
/>
</svg>
</span>
);
}

/** Rough rectangle drawn around the text. */
export function Box({ children, className = "text-lime" }: MarkProps) {
const { ref, drawn } = useDrawn<HTMLSpanElement>();
return (
<span ref={ref} className="relative inline-block px-[0.4em] py-[0.18em]">
{children}
<svg
className={`pointer-events-none absolute inset-0 h-full w-full overflow-visible ${className}`}
viewBox="0 0 300 100"
fill="none"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d="M9 13C88 8 214 7 292 13c4 26 3 49-1 74-79 6-206 7-284 1C3 62 4 39 9 13Z"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
style={strokeStyle(drawn, 1700)}
/>
</svg>
</span>
);
}

/** A line struck through the text. */
export function Strike({ children, className = "text-subtle" }: MarkProps) {
const { ref, drawn } = useDrawn<HTMLSpanElement>();
return (
<span ref={ref} className="relative inline-block">
{children}
<svg
className={`pointer-events-none absolute left-0 top-1/2 h-[0.4em] w-full -translate-y-1/2 overflow-visible ${className}`}
viewBox="0 0 300 10"
fill="none"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d="M4 6c60-3 130 2 190-1 40-2 70 1 102 2"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
pathLength={1}
style={strokeStyle(drawn, 900)}
/>
</svg>
</span>
);
}

/** Marker-style highlight that wipes in behind the text. */
export function Highlight({ children, className = "bg-sun/40" }: MarkProps) {
const { ref, drawn } = useDrawn<HTMLSpanElement>();
return (
<span ref={ref} className="relative inline-block">
<span
className={`absolute inset-x-[-0.15em] bottom-[0.05em] top-[0.4em] origin-left -rotate-1 rounded-[0.2em] ${className}`}
aria-hidden="true"
style={{
transform: `rotate(-1deg) scaleX(${drawn ? 1 : 0})`,
transition: "transform 900ms cubic-bezier(0.65, 0, 0.35, 1)",
}}
/>
<span className="relative">{children}</span>
</span>
);
}
46 changes: 46 additions & 0 deletions src/components/code-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

/**
* Thread code blocks with a copy-to-clipboard button.
*
* Registered as the `pre` element in `src/mdx-components.tsx`. The incoming
* props (including rehype-pretty-code's `data-*` attributes and classes) are
* spread straight onto the real <pre>, so the highlighted output is preserved
* exactly; we only wrap it to position the button.
*/

import { useRef, useState, type ComponentPropsWithoutRef } from "react";

export function Pre(props: ComponentPropsWithoutRef<"pre">) {
const ref = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

async function copy() {
const text = ref.current?.textContent ?? "";
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable (insecure context, denied permission): leave the
// button idle rather than pretending it worked.
}
}

return (
<div className="group relative">
<pre ref={ref} {...props} />
<button
type="button"
onClick={copy}
aria-label={copied ? "Copied to clipboard" : "Copy code to clipboard"}
className="absolute right-3 top-3 rounded-md border border-border bg-card px-2 py-1 font-mono text-[11px] leading-none text-subtle opacity-0 transition-opacity duration-150 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue group-hover:opacity-100"
>
{copied ? "copied" : "copy"}
</button>
</div>
);
}
64 changes: 64 additions & 0 deletions src/components/cron-diagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* A clean cron-expression breakdown for use in threads: each field sits above
* the name and range it maps to, replacing fragile ASCII box-art. Pure server
* component (no client JS). Wildcards render in the accent colour.
*
* Usage in MDX: <CronDiagram expr="0 8 * * *" note="every day at 08:00" />
*/

const FIELDS = [
{ name: "minute", range: "0–59" },
{ name: "hour", range: "0–23" },
{ name: "day-of-month", range: "1–31" },
{ name: "month", range: "1–12" },
{ name: "day-of-week", range: "0–7" },
] as const;

export function CronDiagram({
expr = "0 8 * * *",
note,
}: {
expr?: string;
note?: string;
}) {
const tokens = expr.trim().split(/\s+/).slice(0, 5);

return (
<figure className="my-8">
<div className="overflow-x-auto rounded-panel border border-border bg-card shadow-soft">
<div className="grid min-w-[560px] grid-cols-5">
{FIELDS.map((field, i) => {
const token = tokens[i] ?? "*";
return (
<div
key={field.name}
className={`flex flex-col items-center gap-1.5 px-3 py-5 text-center ${
i > 0 ? "border-l border-border" : ""
}`}
>
<span className="font-mono text-2xl leading-none text-foreground">
{token === "*" ? (
<span className="text-blue">*</span>
) : (
token
)}
</span>
<span className="font-mono text-[11px] leading-none text-subtle">
{field.name}
</span>
<span className="font-mono text-[11px] leading-none text-subtle/60">
{field.range}
</span>
</div>
);
})}
</div>
</div>
{note ? (
<figcaption className="mt-3 text-center font-mono text-xs text-subtle">
<span className="text-blue">β†’</span> {note}
</figcaption>
) : null}
</figure>
);
}
Loading