From ca2bc4eb84512bf30f02c9c576b73af2789a74ba Mon Sep 17 00:00:00 2001 From: boojack Date: Wed, 13 May 2026 09:06:23 +0800 Subject: [PATCH] chore: render placeholder ascii pieces as components --- web/src/components/Placeholder/CREDITS.md | 19 ---- .../components/Placeholder/Placeholder.css | 100 ++++++++++++++++-- web/src/components/Placeholder/ascii-pool.ts | 95 ++++++++--------- web/src/components/Placeholder/index.tsx | 16 +-- .../Placeholder/pieces/BusyHummingbird.tsx | 14 +++ .../Placeholder/pieces/CuriousCrow.tsx | 15 +++ .../Placeholder/pieces/HoveringSwift.tsx | 14 +++ .../Placeholder/pieces/NestingSparrow.tsx | 14 +++ .../Placeholder/pieces/PerchedFinch.tsx | 15 +++ .../Placeholder/pieces/SearchingOwl.tsx | 15 +++ .../Placeholder/pieces/VanishedPerch.tsx | 16 +++ .../Placeholder/pieces/WaywardGull.tsx | 15 +++ web/tests/placeholder-component.test.tsx | 5 +- web/tests/placeholder-pool.test.ts | 34 ++++-- 14 files changed, 286 insertions(+), 101 deletions(-) delete mode 100644 web/src/components/Placeholder/CREDITS.md create mode 100644 web/src/components/Placeholder/pieces/BusyHummingbird.tsx create mode 100644 web/src/components/Placeholder/pieces/CuriousCrow.tsx create mode 100644 web/src/components/Placeholder/pieces/HoveringSwift.tsx create mode 100644 web/src/components/Placeholder/pieces/NestingSparrow.tsx create mode 100644 web/src/components/Placeholder/pieces/PerchedFinch.tsx create mode 100644 web/src/components/Placeholder/pieces/SearchingOwl.tsx create mode 100644 web/src/components/Placeholder/pieces/VanishedPerch.tsx create mode 100644 web/src/components/Placeholder/pieces/WaywardGull.tsx diff --git a/web/src/components/Placeholder/CREDITS.md b/web/src/components/Placeholder/CREDITS.md deleted file mode 100644 index 502c8a19c..000000000 --- a/web/src/components/Placeholder/CREDITS.md +++ /dev/null @@ -1,19 +0,0 @@ -# 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 index 438a8a293..4b55c39b3 100644 --- a/web/src/components/Placeholder/Placeholder.css +++ b/web/src/components/Placeholder/Placeholder.css @@ -1,21 +1,45 @@ /* - * Animations for . + * Animations for piece components. * * Keyframes are wrapped in a prefers-reduced-motion guard so users who opt - * out of motion see a static bird. + * out of motion see static ASCII art. */ @media (prefers-reduced-motion: no-preference) { - .placeholder-motion-bob { - animation: placeholder-bob 3.4s ease-in-out infinite; + .placeholder-perched-finch-bob { + animation: placeholder-gentle-bob 3.4s ease-in-out infinite; } - .placeholder-motion-flutter { - animation: placeholder-flutter 0.7s ease-in-out infinite; + .placeholder-nesting-sparrow-breathe { + animation: placeholder-soft-breathe 4s ease-in-out infinite; + } + + .placeholder-hovering-swift-flutter { + animation: placeholder-quick-flutter 0.75s ease-in-out infinite; + } + + .placeholder-busy-hummingbird-hover { + animation: placeholder-humming-hover 0.55s ease-in-out infinite; + } + + .placeholder-searching-owl-look { + animation: placeholder-watchful-look 4.2s ease-in-out infinite; + } + + .placeholder-curious-crow-tilt { + animation: placeholder-curious-tilt 3.8s ease-in-out infinite; + } + + .placeholder-wayward-gull-drift { + animation: placeholder-slow-drift 3.2s ease-in-out infinite; + } + + .placeholder-vanished-perch-sway { + animation: placeholder-light-sway 4.6s ease-in-out infinite; } } -@keyframes placeholder-bob { +@keyframes placeholder-gentle-bob { 0%, 100% { transform: translateY(0); @@ -25,7 +49,17 @@ } } -@keyframes placeholder-flutter { +@keyframes placeholder-soft-breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.02); + } +} + +@keyframes placeholder-quick-flutter { 0%, 100% { transform: translate(0, 0); @@ -34,3 +68,53 @@ transform: translate(2px, -1px); } } + +@keyframes placeholder-humming-hover { + 0%, + 100% { + transform: translate(0, 0); + } + 50% { + transform: translate(-1px, -2px); + } +} + +@keyframes placeholder-watchful-look { + 0%, + 100% { + transform: translateX(0); + } + 50% { + transform: translateX(2px); + } +} + +@keyframes placeholder-curious-tilt { + 0%, + 100% { + transform: rotate(0deg); + } + 50% { + transform: rotate(-1deg); + } +} + +@keyframes placeholder-slow-drift { + 0%, + 100% { + transform: translateX(0); + } + 50% { + transform: translateX(4px); + } +} + +@keyframes placeholder-light-sway { + 0%, + 100% { + transform: translateX(0); + } + 50% { + transform: translateX(-2px); + } +} diff --git a/web/src/components/Placeholder/ascii-pool.ts b/web/src/components/Placeholder/ascii-pool.ts index 8d1f462d7..032c2481f 100644 --- a/web/src/components/Placeholder/ascii-pool.ts +++ b/web/src/components/Placeholder/ascii-pool.ts @@ -1,71 +1,70 @@ -export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound"; +import type { ComponentType } from "react"; +import BusyHummingbird from "./pieces/BusyHummingbird"; +import CuriousCrow from "./pieces/CuriousCrow"; +import HoveringSwift from "./pieces/HoveringSwift"; +import NestingSparrow from "./pieces/NestingSparrow"; +import PerchedFinch from "./pieces/PerchedFinch"; +import SearchingOwl from "./pieces/SearchingOwl"; +import VanishedPerch from "./pieces/VanishedPerch"; +import WaywardGull from "./pieces/WaywardGull"; -export type MotionStyle = "bob" | "flutter" | "none"; +export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound"; export interface AsciiPiece { id: string; variant: PlaceholderVariant; - ascii: string; credit: string; - motion: MotionStyle; + Component: ComponentType; } export const ASCII_POOL: AsciiPiece[] = [ { - id: "jgs-crested-parrot", + id: "memos-perched-finch", variant: "empty", - credit: "jgs · 4/97", - motion: "bob", - ascii: ` .---. - / 6_6 - \\_ (__\\ - // \\\\ - (( )) -=====""===""===== - ||| - |`, + credit: "Memos original ASCII art", + Component: PerchedFinch, + }, + { + id: "memos-nesting-sparrow", + variant: "empty", + credit: "Memos original ASCII art", + Component: NestingSparrow, + }, + { + id: "memos-hovering-swift", + variant: "loading", + credit: "Memos original ASCII art", + Component: HoveringSwift, }, { - id: "jgs-hummingbird-sm", + id: "memos-busy-hummingbird", variant: "loading", - credit: "jgs · 7/98", - motion: "flutter", - ascii: ` , _ - { \\/\`o;====- - .----'-/\`-/ - \`'-..-| / - /\\/\\ - \`--\``, + credit: "Memos original ASCII art", + Component: BusyHummingbird, + }, + { + id: "memos-searching-owl", + variant: "noResults", + credit: "Memos original ASCII art", + Component: SearchingOwl, }, { - id: "jgs-wide-eyed-owl", + id: "memos-curious-crow", variant: "noResults", - credit: "jgs · 2/01", - motion: "bob", - ascii: ` __ __ - \\ \`-'"'-\` / - / \\_ _/ \\ - | d\\_/b | - .'\\ V /'. - / '-...-' \\ - | / \\ | - \\/\\ /\\/ - ==(||)---(||)==`, + credit: "Memos original ASCII art", + Component: CuriousCrow, + }, + { + id: "memos-wayward-gull", + variant: "notFound", + credit: "Memos original ASCII art", + Component: WaywardGull, }, { - id: "jgs-bird-flown-away", + id: "memos-vanished-perch", variant: "notFound", - credit: "jgs · 7/96", - motion: "flutter", - ascii: ` ___ - _,-' ______ - .' .-' ____7 - / / ___7 - _| / ___7 - >(')\\ | ___7 - \\\\/ \\_______ - ' _======> - \`'----\\\\\``, + credit: "Memos original ASCII art", + Component: VanishedPerch, }, ]; diff --git a/web/src/components/Placeholder/index.tsx b/web/src/components/Placeholder/index.tsx index 3cd365164..2f0a2f5f4 100644 --- a/web/src/components/Placeholder/index.tsx +++ b/web/src/components/Placeholder/index.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useMemo } from "react"; import { cn } from "@/lib/utils"; -import { type MotionStyle, type PlaceholderVariant, pickPiece } from "./ascii-pool"; +import { type PlaceholderVariant, pickPiece } from "./ascii-pool"; import { DEFAULT_MESSAGES } from "./messages"; import "./Placeholder.css"; @@ -11,14 +11,9 @@ interface PlaceholderProps { 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 PieceComponent = piece.Component; const resolvedMessage = message ?? DEFAULT_MESSAGES[variant]; const isLoading = variant === "loading"; @@ -28,12 +23,7 @@ const Placeholder = ({ variant, message, children, className }: PlaceholderProps aria-live={isLoading ? "polite" : undefined} className={cn("flex flex-col items-center justify-center max-w-md mx-auto px-4 py-8", className)} > - +

{resolvedMessage}

{children &&
{children}
} diff --git a/web/src/components/Placeholder/pieces/BusyHummingbird.tsx b/web/src/components/Placeholder/pieces/BusyHummingbird.tsx new file mode 100644 index 000000000..7ff754799 --- /dev/null +++ b/web/src/components/Placeholder/pieces/BusyHummingbird.tsx @@ -0,0 +1,14 @@ +const BusyHummingbird = () => ( + +); + +export default BusyHummingbird; diff --git a/web/src/components/Placeholder/pieces/CuriousCrow.tsx b/web/src/components/Placeholder/pieces/CuriousCrow.tsx new file mode 100644 index 000000000..fa171e946 --- /dev/null +++ b/web/src/components/Placeholder/pieces/CuriousCrow.tsx @@ -0,0 +1,15 @@ +const CuriousCrow = () => ( + +); + +export default CuriousCrow; diff --git a/web/src/components/Placeholder/pieces/HoveringSwift.tsx b/web/src/components/Placeholder/pieces/HoveringSwift.tsx new file mode 100644 index 000000000..cc03d79af --- /dev/null +++ b/web/src/components/Placeholder/pieces/HoveringSwift.tsx @@ -0,0 +1,14 @@ +const HoveringSwift = () => ( + +); + +export default HoveringSwift; diff --git a/web/src/components/Placeholder/pieces/NestingSparrow.tsx b/web/src/components/Placeholder/pieces/NestingSparrow.tsx new file mode 100644 index 000000000..161ea505f --- /dev/null +++ b/web/src/components/Placeholder/pieces/NestingSparrow.tsx @@ -0,0 +1,14 @@ +const NestingSparrow = () => ( + +); + +export default NestingSparrow; diff --git a/web/src/components/Placeholder/pieces/PerchedFinch.tsx b/web/src/components/Placeholder/pieces/PerchedFinch.tsx new file mode 100644 index 000000000..f84984a15 --- /dev/null +++ b/web/src/components/Placeholder/pieces/PerchedFinch.tsx @@ -0,0 +1,15 @@ +const PerchedFinch = () => ( + +); + +export default PerchedFinch; diff --git a/web/src/components/Placeholder/pieces/SearchingOwl.tsx b/web/src/components/Placeholder/pieces/SearchingOwl.tsx new file mode 100644 index 000000000..0876593b6 --- /dev/null +++ b/web/src/components/Placeholder/pieces/SearchingOwl.tsx @@ -0,0 +1,15 @@ +const SearchingOwl = () => ( + +); + +export default SearchingOwl; diff --git a/web/src/components/Placeholder/pieces/VanishedPerch.tsx b/web/src/components/Placeholder/pieces/VanishedPerch.tsx new file mode 100644 index 000000000..2b7d80a6a --- /dev/null +++ b/web/src/components/Placeholder/pieces/VanishedPerch.tsx @@ -0,0 +1,16 @@ +const VanishedPerch = () => ( + +); + +export default VanishedPerch; diff --git a/web/src/components/Placeholder/pieces/WaywardGull.tsx b/web/src/components/Placeholder/pieces/WaywardGull.tsx new file mode 100644 index 000000000..d39f7571d --- /dev/null +++ b/web/src/components/Placeholder/pieces/WaywardGull.tsx @@ -0,0 +1,15 @@ +const WaywardGull = () => ( + +); + +export default WaywardGull; diff --git a/web/tests/placeholder-component.test.tsx b/web/tests/placeholder-component.test.tsx index 3a183fd39..2aa07d178 100644 --- a/web/tests/placeholder-component.test.tsx +++ b/web/tests/placeholder-component.test.tsx @@ -38,9 +38,10 @@ describe("", () => { expect(pre!.textContent!.length).toBeGreaterThan(0); }); - it("does not render the credit string in the UI (attribution lives in CREDITS.md)", () => { + it("does not render registry credit strings in the UI", () => { render(); - expect(screen.queryByText(/jgs/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Memos original ASCII art/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/jgs|Joan Stark/i)).not.toBeInTheDocument(); }); it('applies role="status" and aria-live="polite" ONLY when variant=loading', () => { diff --git a/web/tests/placeholder-pool.test.ts b/web/tests/placeholder-pool.test.ts index f86899762..ff2d9ab81 100644 --- a/web/tests/placeholder-pool.test.ts +++ b/web/tests/placeholder-pool.test.ts @@ -1,3 +1,5 @@ +import { render } from "@testing-library/react"; +import { createElement } from "react"; import { describe, expect, it } from "vitest"; import { ASCII_POOL, pickPiece, type PlaceholderVariant } from "@/components/Placeholder/ascii-pool"; import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages"; @@ -5,10 +7,10 @@ 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", () => { + it("contains at least two pieces per variant", () => { for (const variant of VARIANTS) { const matches = ASCII_POOL.filter((p) => p.variant === variant); - expect(matches.length, `variant=${variant}`).toBeGreaterThanOrEqual(1); + expect(matches.length, `variant=${variant}`).toBeGreaterThanOrEqual(2); } }); @@ -17,15 +19,30 @@ describe("ASCII_POOL integrity", () => { expect(new Set(ids).size).toBe(ids.length); }); - it("preserves the jgs credit on every piece", () => { + it("uses non-empty credits for every piece", () => { for (const piece of ASCII_POOL) { - expect(piece.credit, `piece=${piece.id}`).toMatch(/jgs/); + expect(piece.credit.trim().length, `piece=${piece.id}`).toBeGreaterThan(0); } }); - it("uses a known motion style on every piece", () => { + it("uses original Memos credits for bundled pieces", () => { for (const piece of ASCII_POOL) { - expect(["bob", "flutter", "none"]).toContain(piece.motion); + expect(piece.credit, `piece=${piece.id}`).toContain("Memos"); + expect(piece.credit, `piece=${piece.id}`).not.toMatch(/jgs|Joan Stark/i); + } + }); + + it("registers renderable piece components", () => { + for (const piece of ASCII_POOL) { + const PieceComponent = piece.Component; + const { container, unmount } = render(createElement(PieceComponent)); + const pre = container.querySelector("pre"); + + expect(pre, `piece=${piece.id}`).not.toBeNull(); + expect(pre, `piece=${piece.id}`).toHaveAttribute("aria-hidden", "true"); + expect(pre?.textContent?.trim().length, `piece=${piece.id}`).toBeGreaterThan(0); + + unmount(); } }); }); @@ -37,11 +54,6 @@ describe("pickPiece", () => { 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", () => {