diff --git a/docs/superpowers/plans/2026-05-12-placeholder-component.md b/docs/superpowers/plans/2026-05-12-placeholder-component.md new file mode 100644 index 000000000..8c902b053 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-placeholder-component.md @@ -0,0 +1,776 @@ +# Placeholder Component Implementation Plan + +> **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 `` 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. + +--- + +## File Structure + +**Create:** +- `web/src/components/Placeholder/index.tsx` — public component (default export) +- `web/src/components/Placeholder/Placeholder.css` — keyframes + motion classes +- `web/src/components/Placeholder/ascii-pool.ts` — types, `ASCII_POOL` array, `pickPiece()` +- `web/src/components/Placeholder/messages.ts` — `DEFAULT_MESSAGES` map +- `web/src/components/Placeholder/CREDITS.md` — Joan Stark attribution +- `web/tests/placeholder-pool.test.ts` — picker + pool integrity tests +- `web/tests/placeholder-component.test.tsx` — component render tests + +**Modify:** +- `web/src/pages/Inboxes.tsx` — replace `` with `` + +**Delete:** +- `web/src/components/Empty.tsx` + +--- + +## Task 0: Commit this plan + +**Files:** +- Add: `docs/superpowers/plans/2026-05-12-placeholder-component.md` + +- [ ] **Step 1: Commit the plan document on its own** + +```bash +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. + +--- + +## Task 1: Scaffold pool types and picker (TDD) + +**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`: + +```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); + }); +}); +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +```bash +cd web && pnpm test placeholder-pool +``` + +Expected: FAIL — module `@/components/Placeholder/ascii-pool` not found. + +- [ ] **Step 3: Implement `ascii-pool.ts` with types, an empty pool, and the picker** + +Create `web/src/components/Placeholder/ascii-pool.ts`: + +```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
. */
+  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)];
+}
+```
+
+- [ ] **Step 4: Run the tests and verify they still fail (pool is empty)**
+
+```bash
+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.
+
+- [ ] **Step 5: Commit**
+
+```bash
+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"
+```
+
+---
+
+## Task 2: Seed the four ASCII pieces
+
+**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.
+
+```ts
+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
+             \\\\/     \\_______
+             '        _======>
+             \`'----\\\\\``,
+  },
+];
+```
+
+- [ ] **Step 2: Run the pool tests and verify they pass**
+
+```bash
+cd web && pnpm test placeholder-pool
+```
+
+Expected: PASS for all six assertions.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web/src/components/Placeholder/ascii-pool.ts
+git commit -m "feat(placeholder): seed pool with four jgs ASCII bird pieces"
+```
+
+---
+
+## Task 3: Default messages
+
+**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`:
+
+```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);
+    }
+  });
+});
+```
+
+- [ ] **Step 2: Run the test and verify it fails**
+
+```bash
+cd web && pnpm test placeholder-pool
+```
+
+Expected: FAIL — module `@/components/Placeholder/messages` not found.
+
+- [ ] **Step 3: Create `messages.ts`**
+
+```ts
+import 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.")` lookups via
+ * `react-i18next` without touching the component.
+ */
+export const DEFAULT_MESSAGES: Record = {
+  empty: "No memos yet",
+  loading: "Loading…",
+  noResults: "Nothing matches that search",
+  notFound: "This page flew the coop",
+};
+```
+
+- [ ] **Step 4: Run the test and verify it passes**
+
+```bash
+cd web && pnpm test placeholder-pool
+```
+
+Expected: PASS — all variants have a non-empty default message.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/src/components/Placeholder/messages.ts web/tests/placeholder-pool.test.ts
+git commit -m "feat(placeholder): add DEFAULT_MESSAGES map"
+```
+
+---
+
+## Task 4: Animation keyframes
+
+**Files:**
+- Create: `web/src/components/Placeholder/Placeholder.css`
+
+- [ ] **Step 1: Create the stylesheet**
+
+```css
+/*
+ * Animations for .
+ *
+ * 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; }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/src/components/Placeholder/Placeholder.css
+git commit -m "feat(placeholder): add motion keyframes with reduced-motion guard"
+```
+
+---
+
+## Task 5: Placeholder component (TDD)
+
+**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`:
+
+```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("", () => {
+  it("renders the default message for variant=empty", () => {
+    render();
+    expect(screen.getByText(DEFAULT_MESSAGES.empty)).toBeInTheDocument();
+  });
+
+  it("renders the default message for variant=loading", () => {
+    render();
+    expect(screen.getByText(DEFAULT_MESSAGES.loading)).toBeInTheDocument();
+  });
+
+  it("renders the default message for variant=noResults", () => {
+    render();
+    expect(screen.getByText(DEFAULT_MESSAGES.noResults)).toBeInTheDocument();
+  });
+
+  it("renders the default message for variant=notFound", () => {
+    render();
+    expect(screen.getByText(DEFAULT_MESSAGES.notFound)).toBeInTheDocument();
+  });
+
+  it("overrides the default message when `message` prop is passed", () => {
+    render();
+    expect(screen.getByText("Custom copy goes here")).toBeInTheDocument();
+    expect(screen.queryByText(DEFAULT_MESSAGES.empty)).not.toBeInTheDocument();
+  });
+
+  it("renders the ASCII art inside a 
 with aria-hidden", () => {
+    const { container } = render();
+    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();
+    expect(screen.getByText(/jgs/)).toBeInTheDocument();
+  });
+
+  it('applies role="status" and aria-live="polite" ONLY when variant=loading', () => {
+    const { rerender, container } = render();
+    expect(container.querySelector('[role="status"]')).toBeNull();
+
+    rerender();
+    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(
+      
+        
+      ,
+    );
+    expect(screen.getByRole("button", { name: "Go home" })).toBeInTheDocument();
+  });
+
+  it("merges a custom className onto the outer wrapper", () => {
+    const { container } = render();
+    expect(container.firstChild).toHaveClass("custom-test-class");
+  });
+});
+```
+
+- [ ] **Step 2: Run the tests and verify they fail**
+
+```bash
+cd web && pnpm test placeholder-component
+```
+
+Expected: FAIL — module `@/components/Placeholder` not found.
+
+- [ ] **Step 3: Implement `index.tsx`**
+
+Create `web/src/components/Placeholder/index.tsx`:
+
+```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 = {
+  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 (
+    
+ +

+ {resolvedMessage} +

+

+ {piece.credit} +

+ {children &&
{children}
} +
+ ); +}; + +export default Placeholder; +``` + +- [ ] **Step 4: Run the tests and verify they pass** + +```bash +cd web && pnpm test placeholder-component +``` + +Expected: PASS — all ten assertions green. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/Placeholder/index.tsx web/tests/placeholder-component.test.tsx +git commit -m "feat(placeholder): implement with variant-driven ASCII pool" +``` + +--- + +## Task 6: Attribution credits + +**Files:** +- Create: `web/src/components/Placeholder/CREDITS.md` + +- [ ] **Step 1: Create `CREDITS.md`** + +```markdown +# ASCII Art Credits + +The ASCII bird illustrations rendered by `` 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. +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/components/Placeholder/CREDITS.md +git commit -m "docs(placeholder): credit Joan Stark for ASCII bird art" +``` + +--- + +## Task 7: Wire into `Inboxes.tsx` and delete `Empty.tsx` + +**Files:** +- 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: + +```tsx +{notifications.length === 0 ? ( +
+ +

+ {filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")} +

+
+) : ( +``` + +The outer `
` and the inner `

` both become redundant — `` handles its own centering and message. + +- [ ] **Step 2: Swap the import** + +In `web/src/pages/Inboxes.tsx`, replace the import line: + +```tsx +import Empty from "@/components/Empty"; +``` + +with: + +```tsx +import Placeholder from "@/components/Placeholder"; +``` + +- [ ] **Step 3: Replace the empty-state JSX** + +Replace lines 99–105 (the existing empty-state block above) with: + +```tsx +{notifications.length === 0 ? ( + +) : ( +``` + +(Only the truthy branch of the ternary changes; leave the `: (` start of the falsy branch and everything below it untouched.) + +- [ ] **Step 4: Delete `Empty.tsx`** + +```bash +git rm web/src/components/Empty.tsx +``` + +- [ ] **Step 5: Verify nothing else imports `Empty`** + +```bash +cd /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 `` before continuing. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/pages/Inboxes.tsx web/src/components/Empty.tsx +git commit -m "feat(inboxes): use in place of " +``` + +--- + +## Task 8: Verification — lint, types, tests, build, dev preview + +**Files:** none modified (verification only) + +- [ ] **Step 1: Run the lint + typecheck** + +```bash +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: + +```bash +git add -p web/src web/tests +git commit -m "chore(placeholder): biome auto-fixes" +``` + +- [ ] **Step 2: Run the full test suite** + +```bash +cd web && pnpm test +``` + +Expected: all suites green, including `placeholder-pool` (8 assertions) and `placeholder-component` (10 assertions). Existing tests must still pass. + +- [ ] **Step 3: Run the production build** + +```bash +cd web && pnpm build +``` + +Expected: exits 0. Confirms TypeScript still compiles end-to-end and the new CSS import is picked up by Vite. + +- [ ] **Step 4: Manual visual check — start the dev server** + +```bash +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: + +1. When inbox is empty, the crested-parrot ASCII bird is visible. +2. It bobs gently every ~3.4 seconds. +3. The message text (one of "no unread", "no archived", or "no data") appears below with a soft fade-in. +4. The small `jgs · 4/97` credit is visible below the message. +5. No console errors or warnings. + +Stop the dev server with `Ctrl+C`. + +- [ ] **Step 5: (Optional) Reduced-motion check** + +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). + +- [ ] **Step 6: No-op commit point** + +If steps 1–4 all passed and no further changes were needed, there is nothing to commit. Proceed to the next task. + +--- + +## Task 9: Document the work in the PR body + +**Files:** none modified + +- [ ] **Step 1: Draft a PR description** + +When opening the PR, use a body like: + +```markdown +## Summary + +- Adds a new `` 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 ``. +- 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 `` into the memo search results page. +- Wire `` into the router 404 catch-all. +- Wire `` 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 +``` + +- [ ] **Step 2: Open the PR** (skip if user prefers to do this themselves) + +This step is left as a manual handoff — do not push or open the PR unless the user has explicitly authorized it. + +--- + +## Self-Review Notes + +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, 5 +- `DEFAULT_MESSAGES` — defined in Task 3, consumed in Task 5 +- `placeholder-motion-bob`, `placeholder-motion-flutter`, `placeholder-fade-in` — defined in Task 4 CSS, consumed in Task 5 component +- `cn` 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 diff --git a/docs/superpowers/specs/2026-05-12-placeholder-component-design.md b/docs/superpowers/specs/2026-05-12-placeholder-component-design.md new file mode 100644 index 000000000..7c12ac6bf --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-placeholder-component-design.md @@ -0,0 +1,282 @@ +# Placeholder Component Design + +## Context + +The web frontend currently renders empty states with a single `Empty.tsx` component that displays a lucide `BirdIcon`. It is used in exactly one place (`web/src/pages/Inboxes.tsx`) and offers no support for other state varieties — loading, no-search-results, or 404 pages — each of which is currently handled inconsistently or not at all. + +The goal is to replace `Empty.tsx` with a single reusable `` component that renders a hand-curated ASCII bird illustration plus a short muted message, supports four distinct variants, and ships with the architectural seam needed to grow a randomized pool of ASCII pieces per variant in future work. + +The visual register is "cozy and minimal": muted colors, monospace throughout, gentle motion that respects `prefers-reduced-motion`. The ASCII art itself is drawn from Joan Stark's (jgs) classic ASCII bird collection (https://github.com/oldcompcz/jgs), preserving the `jgs` signature and date as a visible credit beneath each piece. + +## Goals + +- Provide a single `` component covering empty, loading, no-results, and 404 states. +- Render real, recognizable ASCII bird art (not freehand sketches), preserving Joan Stark's attribution. +- Apply subtle CSS-only animation (bob for perched birds, flutter for in-flight birds, fade-in on the message). +- Respect `prefers-reduced-motion` — no animation when the user opts out. +- Provide a pool-shaped data file so future PRs can drop in additional ASCII pieces per variant without component changes. +- Replace the existing `Empty.tsx` in `Inboxes.tsx` as the proof of integration. + +## Non-Goals + +- Adding additional ASCII pieces beyond the four initial picks (one per variant). The pool architecture supports growth, but seeding more pieces is a follow-up. +- Wiring the component into search results, the 404 route, or Suspense fallbacks. Each is a candidate; each is a separate PR. +- Translating the default messages. The file structure leaves a seam for `i18next`, but the initial PR ships plain strings. +- Adding a JS animation library (Framer Motion, react-spring, etc.). Three keyframe animations do not justify a new dependency. +- Visual regression testing infrastructure. + +## Recommended Approach + +Build a single `` component with a `variant` prop that selects a randomized ASCII piece from a co-located pool data file. Animation is CSS-only, with motion presets keyed off each piece's `motion` field. Accessibility uses `aria-hidden` on the decorative ASCII and a semantic `

` for the message. + +This approach has the best balance of present-day simplicity and future extensibility: the initial pool contains one piece per variant, so the component is deterministic today, but the picker function and pool shape impose no constraints on how many pieces are added later. + +## Architecture + +### Public Component + +**Path:** `web/src/components/Placeholder/index.tsx` + +```tsx +import { useMemo } from "react"; +import clsx from "clsx"; +import { pickPiece, MotionStyle, PlaceholderVariant } from "./ascii-pool"; +import { DEFAULT_MESSAGES } from "./messages"; +import "./Placeholder.css"; + +interface PlaceholderProps { + variant: PlaceholderVariant; + message?: string; + children?: React.ReactNode; + className?: string; +} + +const MOTION_CLASS: Record = { + bob: "placeholder-motion-bob", + flutter: "placeholder-motion-flutter", + none: "", +}; + +export function Placeholder({ variant, message, children, className }: PlaceholderProps) { + const piece = useMemo(() => pickPiece(variant), [variant]); + const resolvedMessage = message ?? DEFAULT_MESSAGES[variant]; + const isLoading = variant === "loading"; + + return ( +

+ +

+ {resolvedMessage} +

+

{piece.credit}

+ {children &&
{children}
} +
+ ); +} +``` + +The `useMemo` keyed on `variant` ensures the piece is stable for the mount but re-rolls if the variant prop changes (rare in practice). Re-mounting re-rolls naturally. + +### ASCII Pool + +**Path:** `web/src/components/Placeholder/ascii-pool.ts` + +```ts +export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound"; +export type MotionStyle = "bob" | "flutter" | "none"; + +export interface AsciiPiece { + id: string; + variant: PlaceholderVariant; + ascii: string; + credit: string; + motion: MotionStyle; +} + +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 + \\\\/ \\_______ + ' _======> + \`'----\\\\\``, + }, +]; + +export function pickPiece(variant: PlaceholderVariant): AsciiPiece { + const matches = ASCII_POOL.filter(p => p.variant === variant); + return matches[Math.floor(Math.random() * matches.length)]; +} +``` + +> Each `ascii` value is a template literal; backslashes and backticks are escaped per JS rules. The art shown here is verbatim from the brainstorm sign-off; implementation must preserve every space. + +The `ascii` field of each entry holds the verbatim ASCII string. The four initial pieces are the ones validated during brainstorming: + +- **empty** — Joan Stark's "early-bird" parrot with crest, perched on a branch +- **loading** — Joan Stark's compact hummingbird (mid-flight, fluttering) +- **noResults** — Joan Stark's wide-eyed two-feathered owl +- **notFound** — Joan Stark's flying-away bird with motion trails + +Each piece's `ascii` is committed as a template literal preserving exact whitespace; the file is the source of truth and is small enough (~5–10 lines of art per piece) to keep inline rather than splitting into per-piece files. + +### Default Messages + +**Path:** `web/src/components/Placeholder/messages.ts` + +```ts +export const DEFAULT_MESSAGES: Record = { + empty: "No memos yet", + loading: "Loading…", + noResults: "Nothing matches that search", + notFound: "This page flew the coop", +}; +``` + +Plain strings for the initial PR. A future i18n pass swaps these for `t("placeholder.empty")` etc. without touching the component. + +### Animation + +CSS keyframes live in a small co-located stylesheet (`Placeholder.css`) imported by `index.tsx`, scoped via a `.placeholder-…` class prefix to avoid leakage. The codebase uses Tailwind v4 plus plain CSS imports elsewhere; this matches the existing pattern. + +```css +@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; } +} +``` + +The reduced-motion guard wraps all three rules. When the user prefers reduced motion, the bird is static and the message appears without fading. + +### Accessibility + +- ASCII `
` carries `aria-hidden="true"`; screen readers do not announce it character-by-character.
+- The message is a semantic `

`. It is the accessible name of the placeholder for assistive tech. +- For `variant="loading"` only, the wrapper has `role="status"` and `aria-live="polite"` so the loading message is announced when the placeholder appears. +- The credit line (`jgs · 4/97`) is visible-but-small below the message. It is not aria-hidden because it is intentionally part of the visual presentation. +- The component itself is not focusable. Anything passed as `children` (e.g. a "Go home" button on 404) participates normally in tab order. +- Color contrast relies on the existing `text-muted-foreground` token, which already meets WCAG AA in the project's theme. + +### File Layout + +``` +web/src/components/Placeholder/ + index.tsx # the component (public export) + Placeholder.css # keyframes + .placeholder-motion-* classes + ascii-pool.ts # AsciiPiece type, ASCII_POOL array, pickPiece() + messages.ts # DEFAULT_MESSAGES map (i18n-ready seam) + CREDITS.md # Joan Stark attribution + link to oldcompcz/jgs +``` + +### Integration + +- **Delete:** `web/src/components/Empty.tsx`. +- **Modify:** `web/src/pages/Inboxes.tsx` — replace the `` import and usage with ``. + +Other potential call sites (search results page, router 404 catch-all, Suspense fallbacks) are explicitly out of scope for this PR. They are noted in the PR description as follow-up opportunities. + +### Credits + +**Path:** `web/src/components/Placeholder/CREDITS.md` + +A short Markdown file pointing to https://github.com/oldcompcz/jgs and acknowledging Joan Stark's work. This survives even if the per-piece `credit` field is ever cleaned up by a refactor. + +## Testing + +A small Vitest suite covers: + +- Each variant renders without throwing. +- The chosen `

` content contains text from the matching pool entry.
+- `aria-hidden="true"` on the `
`.
+- `role="status"` only present when `variant="loading"`.
+- A custom `message` prop overrides the default.
+- The credit text is present in the DOM.
+
+No visual regression testing is added.
+
+## Open Questions
+
+None. All design decisions were validated during the brainstorming session — animation register (cozy), bird selections (jgs collection), naming (`Placeholder`, no "bird" in the name), text treatment (plain muted monospace), and integration scope (replace `Empty.tsx`, do not wire other sites yet).
+
+## References
+
+- Joan Stark's ASCII Art Gallery (jgs collection): https://github.com/oldcompcz/jgs
+- Existing component being replaced: `web/src/components/Empty.tsx`
+- Existing call site: `web/src/pages/Inboxes.tsx`
+- Visual brainstorm artifacts: `.superpowers/brainstorm/1991-1778593581/content/` (mascot-approach, animation-vibe, bird-shapes-v2, jgs-bird-set, text-treatment-c)
diff --git a/web/src/components/Empty.tsx b/web/src/components/Empty.tsx
deleted file mode 100644
index ccdd411cc..000000000
--- a/web/src/components/Empty.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { BirdIcon } from "lucide-react";
-
-const Empty = () => {
-  return (
-    
- -
- ); -}; - -export default Empty; diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 24fcc8682..830e029d0 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -12,9 +12,9 @@ import { userKeys } from "@/hooks/useUserQueries"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; -import Empty from "../Empty"; import MemoEditor from "../MemoEditor"; import MemoFilters from "../MemoFilters"; +import Placeholder from "../Placeholder"; import Skeleton from "../Skeleton"; interface Props { @@ -174,10 +174,7 @@ const PagedMemoList = (props: Props) => { {!isFetchingNextPage && ( <> {!hasNextPage && sortedMemoList.length === 0 ? ( -
- -

{t("message.no-data")}

-
+ ) : (
diff --git a/web/src/components/Placeholder/CREDITS.md b/web/src/components/Placeholder/CREDITS.md new file mode 100644 index 000000000..502c8a19c --- /dev/null +++ b/web/src/components/Placeholder/CREDITS.md @@ -0,0 +1,19 @@ +# ASCII Art Credits + +The ASCII bird illustrations rendered by `` 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. diff --git a/web/src/components/Placeholder/Placeholder.css b/web/src/components/Placeholder/Placeholder.css new file mode 100644 index 000000000..438a8a293 --- /dev/null +++ b/web/src/components/Placeholder/Placeholder.css @@ -0,0 +1,36 @@ +/* + * Animations for . + * + * Keyframes are wrapped in a prefers-reduced-motion guard so users who opt + * out of motion see a static bird. + */ + +@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; + } +} + +@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); + } +} diff --git a/web/src/components/Placeholder/ascii-pool.ts b/web/src/components/Placeholder/ascii-pool.ts new file mode 100644 index 000000000..8d1f462d7 --- /dev/null +++ b/web/src/components/Placeholder/ascii-pool.ts @@ -0,0 +1,78 @@ +export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound"; + +export type MotionStyle = "bob" | "flutter" | "none"; + +export interface AsciiPiece { + id: string; + variant: PlaceholderVariant; + ascii: string; + credit: string; + motion: MotionStyle; +} + +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 + \\\\/ \\_______ + ' _======> + \`'----\\\\\``, + }, +]; + +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)]; +} diff --git a/web/src/components/Placeholder/index.tsx b/web/src/components/Placeholder/index.tsx new file mode 100644 index 000000000..3cd365164 --- /dev/null +++ b/web/src/components/Placeholder/index.tsx @@ -0,0 +1,43 @@ +import { type ReactNode, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { type MotionStyle, type PlaceholderVariant, pickPiece } 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 = { + bob: "placeholder-motion-bob", + flutter: "placeholder-motion-flutter", + none: "", +}; + +const Placeholder = ({ variant, message, children, className }: PlaceholderProps) => { + const piece = useMemo(() => pickPiece(variant), [variant]); + const resolvedMessage = message ?? DEFAULT_MESSAGES[variant]; + const isLoading = variant === "loading"; + + return ( +
+ +

{resolvedMessage}

+ {children &&
{children}
} +
+ ); +}; + +export default Placeholder; diff --git a/web/src/components/Placeholder/messages.ts b/web/src/components/Placeholder/messages.ts new file mode 100644 index 000000000..7c3d451fa --- /dev/null +++ b/web/src/components/Placeholder/messages.ts @@ -0,0 +1,10 @@ +import type { PlaceholderVariant } from "./ascii-pool"; + +// Future i18n: swap these for `t("placeholder.")` lookups via +// react-i18next without touching the component. +export const DEFAULT_MESSAGES: Record = { + empty: "No memos yet", + loading: "Loading…", + noResults: "Nothing matches that search", + notFound: "This page flew the coop", +}; diff --git a/web/src/pages/Inboxes.tsx b/web/src/pages/Inboxes.tsx index 927b34311..27c67a7ec 100644 --- a/web/src/pages/Inboxes.tsx +++ b/web/src/pages/Inboxes.tsx @@ -2,10 +2,10 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import { sortBy } from "lodash-es"; import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react"; import { useState } from "react"; -import Empty from "@/components/Empty"; import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage"; import MemoMentionMessage from "@/components/Inbox/MemoMentionMessage"; import MobileHeader from "@/components/MobileHeader"; +import Placeholder from "@/components/Placeholder"; import useMediaQuery from "@/hooks/useMediaQuery"; import { useNotifications } from "@/hooks/useUserQueries"; import { cn } from "@/lib/utils"; @@ -97,12 +97,10 @@ const Inboxes = () => { {/* Notifications List */}
{notifications.length === 0 ? ( -
- -

- {filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")} -

-
+ ) : (
{notifications.map((notification: UserNotification) => { diff --git a/web/tests/placeholder-component.test.tsx b/web/tests/placeholder-component.test.tsx new file mode 100644 index 000000000..3a183fd39 --- /dev/null +++ b/web/tests/placeholder-component.test.tsx @@ -0,0 +1,69 @@ +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("", () => { + it("renders the default message for variant=empty", () => { + render(); + expect(screen.getByText(DEFAULT_MESSAGES.empty)).toBeInTheDocument(); + }); + + it("renders the default message for variant=loading", () => { + render(); + expect(screen.getByText(DEFAULT_MESSAGES.loading)).toBeInTheDocument(); + }); + + it("renders the default message for variant=noResults", () => { + render(); + expect(screen.getByText(DEFAULT_MESSAGES.noResults)).toBeInTheDocument(); + }); + + it("renders the default message for variant=notFound", () => { + render(); + expect(screen.getByText(DEFAULT_MESSAGES.notFound)).toBeInTheDocument(); + }); + + it("overrides the default message when `message` prop is passed", () => { + render(); + expect(screen.getByText("Custom copy goes here")).toBeInTheDocument(); + expect(screen.queryByText(DEFAULT_MESSAGES.empty)).not.toBeInTheDocument(); + }); + + it("renders the ASCII art inside a
 with aria-hidden", () => {
+    const { container } = render();
+    const pre = container.querySelector("pre");
+    expect(pre).not.toBeNull();
+    expect(pre).toHaveAttribute("aria-hidden", "true");
+    expect(pre!.textContent!.length).toBeGreaterThan(0);
+  });
+
+  it("does not render the credit string in the UI (attribution lives in CREDITS.md)", () => {
+    render();
+    expect(screen.queryByText(/jgs/)).not.toBeInTheDocument();
+  });
+
+  it('applies role="status" and aria-live="polite" ONLY when variant=loading', () => {
+    const { rerender, container } = render();
+    expect(container.querySelector('[role="status"]')).toBeNull();
+
+    rerender();
+    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(
+      
+        
+      ,
+    );
+    expect(screen.getByRole("button", { name: "Go home" })).toBeInTheDocument();
+  });
+
+  it("merges a custom className onto the outer wrapper", () => {
+    const { container } = render();
+    expect(container.firstChild).toHaveClass("custom-test-class");
+  });
+});
diff --git a/web/tests/placeholder-pool.test.ts b/web/tests/placeholder-pool.test.ts
new file mode 100644
index 000000000..f86899762
--- /dev/null
+++ b/web/tests/placeholder-pool.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { ASCII_POOL, pickPiece, type PlaceholderVariant } from "@/components/Placeholder/ascii-pool";
+import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
+
+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);
+  });
+});
+
+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);
+    }
+  });
+});