Skip to content

Instantly share code, notes, and snippets.

@kmelve
Created April 2, 2026 16:08
Show Gist options
  • Select an option

  • Save kmelve/0bb6091fdaf3ea8c0f46ca1d1268a7a4 to your computer and use it in GitHub Desktop.

Select an option

Save kmelve/0bb6091fdaf3ea8c0f46ca1d1268a7a4 to your computer and use it in GitHub Desktop.
Em Dash Easter Egg for Sanity + Portable Text — click any em dash to see if it was human-typed or AI-placed

Em Dash Easter Egg

An easter egg for blogs using Sanity and Portable Text: every em dash (—) becomes clickable, revealing whether it was typed by a human or placed by AI.

Human-certified dashes show playful messages like "Certified organic em dash" or "Artisanal punctuation." Uncertified dashes (assumed AI) show messages like "Machine-generated punctuation" or "Algorithmically optimized dash."

How it works

The feature has four layers:

1. Client-side annotation (em-dashes.ts)

A pure function that walks Portable Text blocks at render time, finds em dashes in span text, and splits them into separate spans with a synthetic emDash mark. This mark only exists at render time — nothing is persisted. Spans already marked with humanCertifiedEmDash (from the Studio) are left alone.

2. Interactive component (HumanVerifiedEmDash.tsx)

A 'use client' leaf component that wraps each em dash in an invisible button. Click it and a tooltip appears with a random message based on the mode ("verified" or "ai"). The button inherits all surrounding text styles so it's visually indistinguishable from normal text — only a faint dotted underline hints at interactivity.

3. Sanity annotation (sanity-schema-annotation.ts)

A custom annotation type in the Portable Text schema. Authors can select an em dash in the Studio and mark it as "Human Certified Em Dash" with an optional note. This is stored in the document and survives round-trips.

4. Migration (migration-certify-existing.ts, optional)

A one-shot Sanity migration that certifies all existing em dashes as human-typed — because they were written before the feature existed.

Setup

Dependencies: @portabletext/react, next-sanity (or any Portable Text renderer), React 19, Sanity Studio v3.

  1. Add em-dashes.ts to your lib/utils
  2. Add HumanVerifiedEmDash.tsx to your components
  3. Add the CSS from em-dash-styles.css to your global styles (the animate-fade-in-up class + keyframes may already exist in your project)
  4. Wire up the mark renderers in your Portable Text component (see portable-text-usage.tsx)
  5. Add the humanCertifiedEmDash annotation to your Sanity block schema
  6. (Optional) Run the migration to certify existing em dashes as human-typed

Architecture decisions

  • No server cost: annotateEmDashes is a pure function on already-fetched data — no extra queries
  • No data mutation: The emDash mark is synthesized client-side only, never persisted to Sanity
  • Leaf component: HumanVerifiedEmDash is 'use client' but isolated as a leaf — doesn't cause parent re-renders
  • Allocation-aware: Returns the original array reference when no em dashes exist (avoids unnecessary GC on posts without dashes)
  • Accessible: Differentiated aria-label by mode, role="status" + aria-live="polite" on tooltips

Built for knut.fyi. MIT License.

/**
* Example: Wiring up em dash mark renderers in your Portable Text component.
*
* This shows only the em-dash-related parts — integrate into your existing
* Portable Text setup alongside your other custom marks and types.
*/
import {
PortableText,
type PortableTextBlock,
type PortableTextMarkComponentProps,
} from "@portabletext/react";
import { HumanVerifiedEmDash } from "./HumanVerifiedEmDash";
import { annotateEmDashes } from "./em-dashes";
// Type definitions for the custom marks
interface EmDashMarkValue {
_key: string;
_type: "emDash";
}
interface HumanCertifiedEmDashMarkValue {
_key: string;
_type: "humanCertifiedEmDash";
}
// Add these to your existing `components` object passed to <PortableText />
const emDashMarks = {
// Synthetic mark — applied client-side by annotateEmDashes()
emDash: ({
children,
}: PortableTextMarkComponentProps<EmDashMarkValue>) => {
return <HumanVerifiedEmDash mode="ai">{children}</HumanVerifiedEmDash>;
},
// Persisted mark — set by the author in Sanity Studio
humanCertifiedEmDash: ({
children,
}: PortableTextMarkComponentProps<HumanCertifiedEmDashMarkValue>) => {
return (
<HumanVerifiedEmDash mode="verified">{children}</HumanVerifiedEmDash>
);
},
};
// Example usage in a Server Component
export function BlogBody({ value }: { value: PortableTextBlock[] }) {
// annotateEmDashes runs server-side — no client JS cost
const annotatedValue = annotateEmDashes(value) as PortableTextBlock[];
return (
<PortableText
value={annotatedValue}
components={{
marks: {
// ...your existing marks (links, footnotes, etc.)
...emDashMarks,
},
}}
/>
);
}
/* Em dash easter egg
*
* Add these styles to your global CSS.
* The animate-fade-in-up class + keyframes may already exist
* in your project if you have other tooltip animations.
*/
/* Tooltip animation */
.animate-fade-in-up {
animation: fade-in-up 0.3s ease-out forwards;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translate(-50%, 5px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* Button reset — looks like normal text */
.em-dash-btn {
font: inherit;
background: none;
border: none;
padding: 0;
margin: 0;
color: inherit;
line-height: inherit;
vertical-align: baseline;
cursor: text;
text-decoration: underline dotted;
text-decoration-color: color-mix(in srgb, currentColor 20%, transparent);
text-underline-offset: 3px;
text-decoration-thickness: 1px;
}
.em-dash-btn:hover {
cursor: help;
text-decoration-color: color-mix(in srgb, currentColor 50%, transparent);
}
.em-dash-btn:focus-visible {
outline: 1px dotted var(--muted, #999);
outline-offset: 1px;
}
/**
* Sanity schema: humanCertifiedEmDash annotation
*
* Add this to the `marks.annotations` array in your Portable Text
* block type definition. Authors can select an em dash in the Studio
* and certify it as human-typed.
*
* Example placement inside a defineArrayMember({ type: "block" }) config:
*
* marks: {
* annotations: [
* // ...your existing annotations (links, footnotes, etc.)
* humanCertifiedEmDashAnnotation,
* ],
* },
*/
import { UserIcon } from "@sanity/icons"; // npm install @sanity/icons
import { defineArrayMember, defineField } from "sanity";
export const humanCertifiedEmDashAnnotation = defineArrayMember({
name: "humanCertifiedEmDash",
type: "object",
title: "Human Certified Em Dash",
icon: UserIcon,
description: "Certify this em dash as genuinely human-typed",
fields: [
defineField({
name: "note",
type: "string",
title: "Note",
description: "Optional context about the human certification",
}),
],
});
import { describe, it, expect } from "vitest";
import { annotateEmDashes } from "./em-dashes";
import type { PortableTextBlock } from "next-sanity";
function makeBlock(
children: Array<{
_key: string;
text: string;
marks?: string[];
}>,
markDefs: Array<{ _key: string; _type: string }> = []
): PortableTextBlock {
return {
_type: "block",
_key: "block1",
children: children.map((c) => ({
_type: "span" as const,
...c,
marks: c.marks || [],
})),
markDefs,
};
}
describe("annotateEmDashes", () => {
it("returns the same array reference when no em dashes present", () => {
const blocks = [makeBlock([{ _key: "a1", text: "Hello world" }])];
const result = annotateEmDashes(blocks);
expect(result).toBe(blocks);
});
it("splits a single em dash into 3 spans with markDef", () => {
const blocks = [makeBlock([{ _key: "a1", text: "Hello \u2014 world" }])];
const result = annotateEmDashes(blocks);
expect(result[0].children).toHaveLength(3);
expect((result[0].children as any[])[0]).toMatchObject({
text: "Hello ",
marks: [],
});
expect((result[0].children as any[])[1]).toMatchObject({
text: "\u2014",
marks: ["em-a1-0"],
});
expect((result[0].children as any[])[2]).toMatchObject({
text: " world",
marks: [],
});
expect((result[0].markDefs as any[])).toContainEqual({
_key: "em-a1-0",
_type: "emDash",
});
});
it("handles multiple em dashes in one span", () => {
const blocks = [
makeBlock([{ _key: "a1", text: "one \u2014 two \u2014 three" }]),
];
const result = annotateEmDashes(blocks);
expect(result[0].children).toHaveLength(5);
expect((result[0].children as any[])[0].text).toBe("one ");
expect((result[0].children as any[])[1].text).toBe("\u2014");
expect((result[0].children as any[])[2].text).toBe(" two ");
expect((result[0].children as any[])[3].text).toBe("\u2014");
expect((result[0].children as any[])[4].text).toBe(" three");
expect((result[0].markDefs as any[])).toHaveLength(2);
});
it("handles em dash at start of text", () => {
const blocks = [makeBlock([{ _key: "a1", text: "\u2014world" }])];
const result = annotateEmDashes(blocks);
// Empty string before em dash is omitted
expect(result[0].children).toHaveLength(2);
expect((result[0].children as any[])[0].text).toBe("\u2014");
expect((result[0].children as any[])[1].text).toBe("world");
});
it("handles em dash at end of text", () => {
const blocks = [makeBlock([{ _key: "a1", text: "hello\u2014" }])];
const result = annotateEmDashes(blocks);
expect(result[0].children).toHaveLength(2);
expect((result[0].children as any[])[0].text).toBe("hello");
expect((result[0].children as any[])[1].text).toBe("\u2014");
});
it("preserves existing marks (bold, italic, links) on split spans", () => {
const blocks = [
makeBlock(
[{ _key: "a1", text: "bold \u2014 text", marks: ["strong", "link1"] }],
[{ _key: "link1", _type: "link" }]
),
];
const result = annotateEmDashes(blocks);
const children = result[0].children as any[];
// Text spans keep original marks
expect(children[0].marks).toEqual(["strong", "link1"]);
// Em dash span gets original marks + emDash mark
expect(children[1].marks).toEqual(["strong", "link1", "em-a1-0"]);
expect(children[2].marks).toEqual(["strong", "link1"]);
});
it("skips spans with humanCertifiedEmDash mark", () => {
const blocks = [
makeBlock(
[{ _key: "a1", text: "hello \u2014 world", marks: ["cert1"] }],
[{ _key: "cert1", _type: "humanCertifiedEmDash" }]
),
];
const result = annotateEmDashes(blocks);
// Span should be untouched
expect(result[0].children).toHaveLength(1);
expect((result[0].children as any[])[0].text).toBe("hello \u2014 world");
});
it("skips non-block types (images, code blocks)", () => {
const imageBlock = {
_type: "mainImage",
_key: "img1",
asset: { _ref: "image-123" },
} as unknown as PortableTextBlock;
const result = annotateEmDashes([imageBlock]);
expect(result[0]).toBe(imageBlock);
});
it("handles empty text spans", () => {
const blocks = [makeBlock([{ _key: "a1", text: "" }])];
const result = annotateEmDashes(blocks);
expect(result[0].children).toHaveLength(1);
expect((result[0].children as any[])[0].text).toBe("");
});
it("handles span that is only an em dash", () => {
const blocks = [makeBlock([{ _key: "a1", text: "\u2014" }])];
const result = annotateEmDashes(blocks);
expect(result[0].children).toHaveLength(1);
expect((result[0].children as any[])[0].text).toBe("\u2014");
expect((result[0].children as any[])[0].marks).toEqual(["em-a1-0"]);
});
it("does not mutate original blocks", () => {
const original = [makeBlock([{ _key: "a1", text: "hello \u2014 world" }])];
const originalJson = JSON.stringify(original);
annotateEmDashes(original);
expect(JSON.stringify(original)).toBe(originalJson);
});
});
import type { PortableTextBlock } from "next-sanity";
const EM_DASH = "\u2014";
interface Span {
_type: "span";
_key: string;
text: string;
marks: string[];
}
interface MarkDef {
_key: string;
_type: string;
[key: string]: unknown;
}
/**
* Walk Portable Text blocks, find em dashes in span text,
* split into separate spans with emDash marks.
* Skips spans that already have a humanCertifiedEmDash mark.
*/
export function annotateEmDashes(
blocks: PortableTextBlock[]
): PortableTextBlock[] {
let hasChanges = false;
const result = blocks.map((block) => {
if (block._type !== "block") return block;
const markDefs: MarkDef[] = (block.markDefs as MarkDef[]) || [];
const children: Span[] = (block.children as Span[]) || [];
// Check if any uncertified spans contain em dashes
const certifiedKeys = new Set(
markDefs
.filter((def) => def._type === "humanCertifiedEmDash")
.map((def) => def._key)
);
const hasEmDashes = children.some(
(child) =>
child._type === "span" &&
child.text.includes(EM_DASH) &&
!child.marks?.some((mark) => certifiedKeys.has(mark))
);
if (!hasEmDashes) return block;
hasChanges = true;
const newMarkDefs: MarkDef[] = [...markDefs];
const newChildren: Span[] = [];
for (const child of children) {
if (child._type !== "span" || !child.text.includes(EM_DASH)) {
newChildren.push(child);
continue;
}
// Skip if this span is already certified as human
const isCertified = child.marks?.some((mark) => certifiedKeys.has(mark));
if (isCertified) {
newChildren.push(child);
continue;
}
// Split the text around em dashes
const parts = child.text.split(EM_DASH);
const existingMarks = child.marks || [];
parts.forEach((part, i) => {
// Add the text before/between/after em dashes
if (part !== "") {
newChildren.push({
_type: "span",
_key: `${child._key}-r-${i}`,
text: part,
marks: [...existingMarks],
});
}
// Add the em dash span (except after the last part)
if (i < parts.length - 1) {
const markKey = `em-${child._key}-${i}`;
newMarkDefs.push({ _key: markKey, _type: "emDash" });
newChildren.push({
_type: "span",
_key: `${child._key}-em-${i}`,
text: EM_DASH,
marks: [...existingMarks, markKey],
});
}
});
}
return {
...block,
children: newChildren,
markDefs: newMarkDefs,
};
});
return hasChanges ? result : blocks;
}
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
const verifiedMessages = [
"Certified organic em dash",
"Hand-crafted by a human",
"Free-range em dash",
"Artisanal punctuation",
"Locally sourced grammar",
"Human-verified \u2014 no AI involved",
"This dash was typed with real fingers",
"100% natural punctuation",
];
const aiMessages = [
"This em dash was placed by AI",
"Machine-generated punctuation",
"Algorithmically optimized dash",
"AI-assisted em dash \u2014 beep boop",
"Placed here by a large language model",
"Not hand-crafted, but still valid",
];
interface HumanVerifiedEmDashProps {
mode?: "verified" | "ai";
children?: React.ReactNode;
}
export function HumanVerifiedEmDash({
mode = "ai",
children,
}: HumanVerifiedEmDashProps) {
const [showTooltip, setShowTooltip] = useState(false);
const [message, setMessage] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleClick = useCallback(() => {
const messages = mode === "verified" ? verifiedMessages : aiMessages;
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
setMessage(randomMessage);
setShowTooltip(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setShowTooltip(false), 2500);
}, [mode]);
return (
<span className="relative inline">
<button
onClick={handleClick}
className="em-dash-btn"
type="button"
aria-label={mode === "verified" ? "Human-certified em dash" : "Em dash — click for details"}
>
{children ?? "\u2014"}
</button>
{showTooltip && (
<span
className="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap bg-foreground text-background text-xs px-2 py-1 rounded animate-fade-in-up z-10"
role="status"
aria-live="polite"
>
{message}
</span>
)}
</span>
);
}
import { defineMigration, at, set } from "sanity/migrate";
const EM_DASH = "\u2014";
/**
* Migration: Certify all existing em dashes as human-typed
*
* All em dashes in current content were typed by a human before the
* easter egg feature existed. This migration walks body blocks and
* wraps each em dash in a humanCertifiedEmDash annotation so they
* show "Certified organic em dash" instead of AI-suspicious messages.
*/
interface Span {
_type: "span";
_key: string;
text: string;
marks?: string[];
}
interface MarkDef {
_key: string;
_type: string;
[key: string]: unknown;
}
interface Block {
_type: string;
_key: string;
children?: Span[];
markDefs?: MarkDef[];
}
function certifyEmDashesInBody(body: Block[]): Block[] | null {
let changed = false;
const result = body.map((block) => {
if (block._type !== "block") return block;
const children: Span[] = block.children || [];
const markDefs: MarkDef[] = block.markDefs || [];
// Skip blocks that already have humanCertifiedEmDash markDefs
if (markDefs.some((def) => def._type === "humanCertifiedEmDash")) {
return block;
}
const newMarkDefs: MarkDef[] = [...markDefs];
const newChildren: Span[] = [];
let blockChanged = false;
for (const child of children) {
if (child._type !== "span" || !child.text.includes(EM_DASH)) {
newChildren.push(child);
continue;
}
blockChanged = true;
const parts = child.text.split(EM_DASH);
const existingMarks = child.marks || [];
parts.forEach((part, i) => {
if (part !== "") {
newChildren.push({
_type: "span",
_key: `${child._key}-r-${i}`,
text: part,
marks: [...existingMarks],
});
}
if (i < parts.length - 1) {
const markKey = `hcem-${child._key}-${i}`;
newMarkDefs.push({ _key: markKey, _type: "humanCertifiedEmDash" });
newChildren.push({
_type: "span",
_key: `${child._key}-em-${i}`,
text: EM_DASH,
marks: [...existingMarks, markKey],
});
}
});
}
if (!blockChanged) return block;
changed = true;
return {
...block,
children: newChildren,
markDefs: newMarkDefs,
};
});
return changed ? result : null;
}
export default defineMigration({
title: "Certify existing em dashes as human-typed",
documentTypes: ["post", "publication", "project"],
migrate: {
document(doc) {
const body = doc.body as Block[] | undefined;
if (!body || !Array.isArray(body)) return;
const transformed = certifyEmDashesInBody(body);
if (!transformed) return;
return [at("body", set(transformed))];
},
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment