docs/superpowers/plans/2026-05-12-placeholder-component.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace Empty.tsx with a reusable <Placeholder> component covering empty / loading / noResults / notFound states, each rendering a hand-curated ASCII bird from a pool-shaped data file with subtle CSS-only animation.
Architecture: Single component (Placeholder/index.tsx) reads from a co-located ascii-pool.ts data file via a pickPiece(variant) picker. Motion is CSS keyframes in Placeholder.css, gated by prefers-reduced-motion. Default messages live in a messages.ts seam ready for future i18n. Integration is narrow: only Inboxes.tsx is rewired in this PR; Empty.tsx is deleted.
Tech Stack: React 19 · TypeScript · Tailwind v4 (via @tailwindcss/vite) · Vitest + @testing-library/react + jsdom · Biome for lint/format · cn helper from @/lib/utils for class composition.
Create:
web/src/components/Placeholder/index.tsx — public component (default export)web/src/components/Placeholder/Placeholder.css — keyframes + motion classesweb/src/components/Placeholder/ascii-pool.ts — types, ASCII_POOL array, pickPiece()web/src/components/Placeholder/messages.ts — DEFAULT_MESSAGES mapweb/src/components/Placeholder/CREDITS.md — Joan Stark attributionweb/tests/placeholder-pool.test.ts — picker + pool integrity testsweb/tests/placeholder-component.test.tsx — component render testsModify:
web/src/pages/Inboxes.tsx — replace <Empty /> with <Placeholder variant="empty" message={…} />Delete:
web/src/components/Empty.tsxFiles:
Add: docs/superpowers/plans/2026-05-12-placeholder-component.md
Step 1: Commit the plan document on its own
git add docs/superpowers/plans/2026-05-12-placeholder-component.md
git commit -m "docs: add Placeholder component implementation plan"
This keeps the planning artifact separate from feature commits.
Files:
Create: web/src/components/Placeholder/ascii-pool.ts
Test: web/tests/placeholder-pool.test.ts
Step 1: Write the failing tests for pickPiece and pool shape
Create web/tests/placeholder-pool.test.ts:
import { describe, expect, it } from "vitest";
import { ASCII_POOL, pickPiece, type PlaceholderVariant } from "@/components/Placeholder/ascii-pool";
const VARIANTS: PlaceholderVariant[] = ["empty", "loading", "noResults", "notFound"];
describe("ASCII_POOL integrity", () => {
it("contains at least one piece per variant", () => {
for (const variant of VARIANTS) {
const matches = ASCII_POOL.filter((p) => p.variant === variant);
expect(matches.length, `variant=${variant}`).toBeGreaterThanOrEqual(1);
}
});
it("uses unique ids", () => {
const ids = ASCII_POOL.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("preserves the jgs credit on every piece", () => {
for (const piece of ASCII_POOL) {
expect(piece.credit, `piece=${piece.id}`).toMatch(/jgs/);
}
});
it("uses a known motion style on every piece", () => {
for (const piece of ASCII_POOL) {
expect(["bob", "flutter", "none"]).toContain(piece.motion);
}
});
});
describe("pickPiece", () => {
it("returns a piece matching the requested variant", () => {
for (const variant of VARIANTS) {
const piece = pickPiece(variant);
expect(piece.variant).toBe(variant);
}
});
it("returns a non-empty ascii string", () => {
const piece = pickPiece("empty");
expect(piece.ascii.length).toBeGreaterThan(0);
});
});
cd web && pnpm test placeholder-pool
Expected: FAIL — module @/components/Placeholder/ascii-pool not found.
ascii-pool.ts with types, an empty pool, and the pickerCreate web/src/components/Placeholder/ascii-pool.ts:
export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound";
export type MotionStyle = "bob" | "flutter" | "none";
export interface AsciiPiece {
/** Stable identifier — used as React key and for debugging. */
id: string;
/** Which placeholder state this piece is shown for. */
variant: PlaceholderVariant;
/** ASCII art preserved verbatim — must keep every space. */
ascii: string;
/** Attribution shown beneath the bird, e.g. "jgs · 4/97". */
credit: string;
/** Motion hint applied to the <pre>. */
motion: MotionStyle;
}
export const ASCII_POOL: AsciiPiece[] = [];
export function pickPiece(variant: PlaceholderVariant): AsciiPiece {
const matches = ASCII_POOL.filter((p) => p.variant === variant);
if (matches.length === 0) {
throw new Error(`No ASCII piece registered for variant "${variant}"`);
}
return matches[Math.floor(Math.random() * matches.length)];
}
cd web && pnpm test placeholder-pool
Expected: FAIL — "contains at least one piece per variant" expectations not met (because ASCII_POOL is []).
This is the expected red — pool gets seeded in the next task.
git add web/src/components/Placeholder/ascii-pool.ts web/tests/placeholder-pool.test.ts
git commit -m "feat(placeholder): scaffold ASCII pool types and picker"
Files:
Modify: web/src/components/Placeholder/ascii-pool.ts
Step 1: Replace the empty ASCII_POOL array with the four seed entries
In web/src/components/Placeholder/ascii-pool.ts, replace export const ASCII_POOL: AsciiPiece[] = []; with the four entries below. Preserve every space and newline in the ascii strings exactly — they are template literals with escaped backslashes/backticks per JS rules.
export const ASCII_POOL: AsciiPiece[] = [
{
id: "jgs-crested-parrot",
variant: "empty",
credit: "jgs · 4/97",
motion: "bob",
ascii: ` .---.
/ 6_6
\\_ (__\\
// \\\\
(( ))
=====""===""=====
|||
|`,
},
{
id: "jgs-hummingbird-sm",
variant: "loading",
credit: "jgs · 7/98",
motion: "flutter",
ascii: ` , _
{ \\/\`o;====-
.----'-/\`-/
\`'-..-| /
/\\/\\
\`--\``,
},
{
id: "jgs-wide-eyed-owl",
variant: "noResults",
credit: "jgs · 2/01",
motion: "bob",
ascii: ` __ __
\\ \`-'"'-\` /
/ \\_ _/ \\
| d\\_/b |
.'\\ V /'.
/ '-...-' \\
| / \\ |
\\/\\ /\\/
==(||)---(||)==`,
},
{
id: "jgs-bird-flown-away",
variant: "notFound",
credit: "jgs · 7/96",
motion: "flutter",
ascii: ` ___
_,-' ______
.' .-' ____7
/ / ___7
_| / ___7
>(')\\ | ___7
\\\\/ \\_______
' _======>
\`'----\\\\\``,
},
];
cd web && pnpm test placeholder-pool
Expected: PASS for all six assertions.
git add web/src/components/Placeholder/ascii-pool.ts
git commit -m "feat(placeholder): seed pool with four jgs ASCII bird pieces"
Files:
Create: web/src/components/Placeholder/messages.ts
Step 1: Add a tiny test for the messages map
Append to web/tests/placeholder-pool.test.ts:
import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
describe("DEFAULT_MESSAGES", () => {
it("provides a non-empty message for every variant", () => {
for (const variant of VARIANTS) {
expect(DEFAULT_MESSAGES[variant], `variant=${variant}`).toBeTruthy();
expect(DEFAULT_MESSAGES[variant].trim().length).toBeGreaterThan(0);
}
});
});
cd web && pnpm test placeholder-pool
Expected: FAIL — module @/components/Placeholder/messages not found.
messages.tsimport type { PlaceholderVariant } from "./ascii-pool";
/**
* Default copy shown beneath the ASCII art when no `message` prop is supplied.
*
* Future i18n: swap these strings for `t("placeholder.<variant>")` lookups via
* `react-i18next` without touching the component.
*/
export const DEFAULT_MESSAGES: Record<PlaceholderVariant, string> = {
empty: "No memos yet",
loading: "Loading…",
noResults: "Nothing matches that search",
notFound: "This page flew the coop",
};
cd web && pnpm test placeholder-pool
Expected: PASS — all variants have a non-empty default message.
git add web/src/components/Placeholder/messages.ts web/tests/placeholder-pool.test.ts
git commit -m "feat(placeholder): add DEFAULT_MESSAGES map"
Files:
Create: web/src/components/Placeholder/Placeholder.css
Step 1: Create the stylesheet
/*
* Animations for <Placeholder>.
*
* All keyframes are wrapped in a prefers-reduced-motion guard so users who
* opt out of motion see a static bird and an instantly-visible message.
*/
@media (prefers-reduced-motion: no-preference) {
.placeholder-motion-bob {
animation: placeholder-bob 3.4s ease-in-out infinite;
}
.placeholder-motion-flutter {
animation: placeholder-flutter 0.7s ease-in-out infinite;
}
.placeholder-fade-in {
animation: placeholder-fade 1s ease-out 0.3s both;
opacity: 0;
}
}
@keyframes placeholder-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes placeholder-flutter {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(2px, -1px); }
}
@keyframes placeholder-fade {
to { opacity: 1; }
}
git add web/src/components/Placeholder/Placeholder.css
git commit -m "feat(placeholder): add motion keyframes with reduced-motion guard"
Files:
Create: web/src/components/Placeholder/index.tsx
Test: web/tests/placeholder-component.test.tsx
Step 1: Write the failing component tests
Create web/tests/placeholder-component.test.tsx:
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Placeholder from "@/components/Placeholder";
import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
describe("<Placeholder>", () => {
it("renders the default message for variant=empty", () => {
render(<Placeholder variant="empty" />);
expect(screen.getByText(DEFAULT_MESSAGES.empty)).toBeInTheDocument();
});
it("renders the default message for variant=loading", () => {
render(<Placeholder variant="loading" />);
expect(screen.getByText(DEFAULT_MESSAGES.loading)).toBeInTheDocument();
});
it("renders the default message for variant=noResults", () => {
render(<Placeholder variant="noResults" />);
expect(screen.getByText(DEFAULT_MESSAGES.noResults)).toBeInTheDocument();
});
it("renders the default message for variant=notFound", () => {
render(<Placeholder variant="notFound" />);
expect(screen.getByText(DEFAULT_MESSAGES.notFound)).toBeInTheDocument();
});
it("overrides the default message when `message` prop is passed", () => {
render(<Placeholder variant="empty" message="Custom copy goes here" />);
expect(screen.getByText("Custom copy goes here")).toBeInTheDocument();
expect(screen.queryByText(DEFAULT_MESSAGES.empty)).not.toBeInTheDocument();
});
it("renders the ASCII art inside a <pre> with aria-hidden", () => {
const { container } = render(<Placeholder variant="empty" />);
const pre = container.querySelector("pre");
expect(pre).not.toBeNull();
expect(pre).toHaveAttribute("aria-hidden", "true");
expect(pre!.textContent!.length).toBeGreaterThan(0);
});
it("renders a jgs credit string", () => {
render(<Placeholder variant="empty" />);
expect(screen.getByText(/jgs/)).toBeInTheDocument();
});
it('applies role="status" and aria-live="polite" ONLY when variant=loading', () => {
const { rerender, container } = render(<Placeholder variant="empty" />);
expect(container.querySelector('[role="status"]')).toBeNull();
rerender(<Placeholder variant="loading" />);
const live = container.querySelector('[role="status"]');
expect(live).not.toBeNull();
expect(live).toHaveAttribute("aria-live", "polite");
});
it("renders children below the message when provided", () => {
render(
<Placeholder variant="notFound">
<button type="button">Go home</button>
</Placeholder>,
);
expect(screen.getByRole("button", { name: "Go home" })).toBeInTheDocument();
});
it("merges a custom className onto the outer wrapper", () => {
const { container } = render(<Placeholder variant="empty" className="custom-test-class" />);
expect(container.firstChild).toHaveClass("custom-test-class");
});
});
cd web && pnpm test placeholder-component
Expected: FAIL — module @/components/Placeholder not found.
index.tsxCreate web/src/components/Placeholder/index.tsx:
import { useMemo, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { pickPiece, type MotionStyle, type PlaceholderVariant } from "./ascii-pool";
import { DEFAULT_MESSAGES } from "./messages";
import "./Placeholder.css";
interface PlaceholderProps {
variant: PlaceholderVariant;
message?: string;
children?: ReactNode;
className?: string;
}
const MOTION_CLASS: Record<MotionStyle, string> = {
bob: "placeholder-motion-bob",
flutter: "placeholder-motion-flutter",
none: "",
};
const Placeholder = ({ variant, message, children, className }: PlaceholderProps) => {
// Stable for the lifetime of this mount; re-rolls only if `variant` changes
// (which is rare in practice — most callers pass a constant).
const piece = useMemo(() => pickPiece(variant), [variant]);
const resolvedMessage = message ?? DEFAULT_MESSAGES[variant];
const isLoading = variant === "loading";
return (
<div
role={isLoading ? "status" : undefined}
aria-live={isLoading ? "polite" : undefined}
className={cn("flex flex-col items-center justify-center max-w-md mx-auto px-4 py-8", className)}
>
<pre
aria-hidden="true"
className={cn(
"font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0",
MOTION_CLASS[piece.motion],
)}
>
{piece.ascii}
</pre>
<p className="mt-3 font-mono text-sm text-muted-foreground placeholder-fade-in">
{resolvedMessage}
</p>
<p className="mt-1 font-mono text-[10px] text-muted-foreground/60 placeholder-fade-in">
{piece.credit}
</p>
{children && <div className="mt-4">{children}</div>}
</div>
);
};
export default Placeholder;
cd web && pnpm test placeholder-component
Expected: PASS — all ten assertions green.
git add web/src/components/Placeholder/index.tsx web/tests/placeholder-component.test.tsx
git commit -m "feat(placeholder): implement <Placeholder> with variant-driven ASCII pool"
Files:
Create: web/src/components/Placeholder/CREDITS.md
Step 1: Create CREDITS.md
# ASCII Art Credits
The ASCII bird illustrations rendered by `<Placeholder>` are from **Joan Stark's**
classic ASCII art collection. Each piece is signed with her `jgs` tag and the
month/year it was published.
- Source archive: https://github.com/oldcompcz/jgs (Joan Stark's ASCII Art Gallery)
- Original site (preserved via WebArchive): https://web.archive.org/web/20091028013825/http://www.geocities.com/SoHo/7373/
- Wikipedia: https://en.wikipedia.org/wiki/Joan_Stark
Joan Stark distributed her art freely on Usenet and the early web. We retain
the `jgs` signature visible beneath each piece in the UI so attribution travels
with the art wherever it is shown.
If you add new ASCII pieces to `ascii-pool.ts`:
- Prefer well-attributed art from established collections.
- Keep the original artist signature in the `credit` field (e.g. `"jgs · 4/97"`).
- If using a different artist, link the source in this file.
git add web/src/components/Placeholder/CREDITS.md
git commit -m "docs(placeholder): credit Joan Stark for ASCII bird art"
Inboxes.tsx and delete Empty.tsxFiles:
Modify: web/src/pages/Inboxes.tsx
Delete: web/src/components/Empty.tsx
Step 1: Read the current empty-state block in Inboxes.tsx
Open web/src/pages/Inboxes.tsx. The relevant block is at lines 99–105:
{notifications.length === 0 ? (
<div className="w-full py-16 flex flex-col justify-center items-center">
<Empty />
<p className="mt-4 text-sm text-muted-foreground">
{filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")}
</p>
</div>
) : (
The outer <div className="w-full py-16 flex flex-col justify-center items-center"> and the inner <p> both become redundant — <Placeholder> handles its own centering and message.
In web/src/pages/Inboxes.tsx, replace the import line:
import Empty from "@/components/Empty";
with:
import Placeholder from "@/components/Placeholder";
Replace lines 99–105 (the existing empty-state block above) with:
{notifications.length === 0 ? (
<Placeholder
variant="empty"
message={
filter === "unread"
? t("inbox.no-unread")
: filter === "archived"
? t("inbox.no-archived")
: t("message.no-data")
}
/>
) : (
(Only the truthy branch of the ternary changes; leave the : ( start of the falsy branch and everything below it untouched.)
Empty.tsxgit rm web/src/components/Empty.tsx
Emptycd /Users/steven/Projects/usememos/memos && grep -rn 'from "@/components/Empty"\|from "./Empty"\|from "../Empty"' web/src 2>/dev/null
Expected: no output. If anything matches, update that file to use <Placeholder variant="empty" /> before continuing.
git add web/src/pages/Inboxes.tsx web/src/components/Empty.tsx
git commit -m "feat(inboxes): use <Placeholder variant=empty> in place of <Empty>"
Files: none modified (verification only)
cd web && pnpm lint
Expected: exits 0. If Biome flags anything (formatting, sort-order, unused imports), run pnpm lint:fix and re-run pnpm lint. Commit the fix separately if any changes are required:
git add -p web/src web/tests
git commit -m "chore(placeholder): biome auto-fixes"
cd web && pnpm test
Expected: all suites green, including placeholder-pool (8 assertions) and placeholder-component (10 assertions). Existing tests must still pass.
cd web && pnpm build
Expected: exits 0. Confirms TypeScript still compiles end-to-end and the new CSS import is picked up by Vite.
cd web && pnpm dev
In a browser, navigate to the running URL (Vite prints it). Sign in with any test account, open the Inbox page, and confirm:
jgs · 4/97 credit is visible below the message.Stop the dev server with Ctrl+C.
Open browser DevTools → command menu → "Emulate CSS prefers-reduced-motion: reduce". Reload the inbox empty state. The bird should be static and the message should appear instantly (no fade).
If steps 1–4 all passed and no further changes were needed, there is nothing to commit. Proceed to the next task.
Files: none modified
When opening the PR, use a body like:
## Summary
- Adds a new `<Placeholder variant="empty | loading | noResults | notFound">` component that renders a hand-curated ASCII bird from a pool-shaped data file, with subtle CSS-only motion that respects `prefers-reduced-motion`.
- Replaces the single-purpose `Empty.tsx` (used in `Inboxes.tsx`) with `<Placeholder variant="empty">`.
- ASCII art is from Joan Stark's (jgs) classic collection — attribution is preserved on every piece and in a co-located `CREDITS.md`.
## Out of scope (follow-up opportunities)
- Wire `<Placeholder variant="noResults">` into the memo search results page.
- Wire `<Placeholder variant="notFound">` into the router 404 catch-all.
- Wire `<Placeholder variant="loading">` into Suspense fallbacks.
- Seed additional ASCII pieces per variant — the pool architecture supports it; just append entries to `ASCII_POOL`.
## Test plan
- [ ] `pnpm lint` clean
- [ ] `pnpm test` green (incl. new `placeholder-pool` and `placeholder-component` suites)
- [ ] `pnpm build` succeeds
- [ ] Inbox empty state shows the ASCII parrot, bobs, and renders the filter-specific message
- [ ] `prefers-reduced-motion: reduce` produces a static bird and an instantly-visible message
This step is left as a manual handoff — do not push or open the PR unless the user has explicitly authorized it.
This plan covers the spec's nine sections as follows:
| Spec section | Implemented by |
|---|---|
| Public Component | Task 5 |
| ASCII Pool | Tasks 1, 2 |
| Default Messages | Task 3 |
| Animation | Task 4 (CSS) + Task 5 (component wires classes) |
| Accessibility | Task 5 (test assertions + impl) |
| File Layout | Tasks 1–6 (all five files created) |
| Integration | Task 7 (Inboxes rewire + Empty delete) |
| Credits | Task 6 |
| Testing | Tasks 1, 3, 5 (pool tests + component tests) |
No spec section is unimplemented. No "TBD" / "TODO" / vague-handwave language is used in any step. Types, method signatures, and class names referenced across tasks match:
PlaceholderVariant, MotionStyle, AsciiPiece, ASCII_POOL, pickPiece — consistent across Tasks 1, 2, 3, 5DEFAULT_MESSAGES — defined in Task 3, consumed in Task 5placeholder-motion-bob, placeholder-motion-flutter, placeholder-fade-in — defined in Task 4 CSS, consumed in Task 5 componentcn from @/lib/utils — matches existing codebase convention (verified in pre-plan exploration)Placeholder is a default export — matches the convention used by Empty.tsx and most other web/src/components/*.tsx files