chore: render placeholder ascii pieces as components

pull/5918/merge
boojack 3 weeks ago
parent aa5cb455e9
commit ca2bc4eb84

@ -1,19 +0,0 @@
# 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.

@ -1,21 +1,45 @@
/*
* Animations for <Placeholder>.
* Animations for <Placeholder> 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);
}
}

@ -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,
},
];

@ -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<MotionStyle, string> = {
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)}
>
<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>
<PieceComponent />
<p className="mt-3 font-mono text-sm text-muted-foreground">{resolvedMessage}</p>
{children && <div className="mt-4">{children}</div>}
</div>

@ -0,0 +1,14 @@
const BusyHummingbird = () => (
<pre
aria-hidden="true"
className="font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0 placeholder-busy-hummingbird-hover"
>
{String.raw` ,_
--=={o )>
/ ) )
/_/'
.- .-`}
</pre>
);
export default BusyHummingbird;

@ -0,0 +1,15 @@
const CuriousCrow = () => (
<pre
aria-hidden="true"
className="font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0 placeholder-curious-crow-tilt"
>
{String.raw` __
___(o )>
/ ._ )
/__/ )/
\_//
^^`}
</pre>
);
export default CuriousCrow;

@ -0,0 +1,14 @@
const HoveringSwift = () => (
<pre
aria-hidden="true"
className="font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0 placeholder-hovering-swift-flutter"
>
{String.raw` __
--<(o )___
\ _/
'-'
. . .`}
</pre>
);
export default HoveringSwift;

@ -0,0 +1,14 @@
const NestingSparrow = () => (
<pre
aria-hidden="true"
className="font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0 placeholder-nesting-sparrow-breathe"
>
{String.raw` _
__(.)<
/___)
~\~~~~~~/~
\____/`}
</pre>
);
export default NestingSparrow;

@ -0,0 +1,15 @@
const PerchedFinch = () => (
<pre
aria-hidden="true"
className="font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0 placeholder-perched-finch-bob"
>
{String.raw` .-.
(o o)
/ V /
/( _ )/
^^ ^^
----| |----`}
</pre>
);
export default PerchedFinch;

@ -0,0 +1,15 @@
const SearchingOwl = () => (
<pre
aria-hidden="true"
className="font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0 placeholder-searching-owl-look"
>
{String.raw` ___
/ o o /
( V )
\ - /
/| |/
/_|___|_/`}
</pre>
);
export default SearchingOwl;

@ -0,0 +1,16 @@
const VanishedPerch = () => (
<pre
aria-hidden="true"
className="font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0 placeholder-vanished-perch-sway"
>
{String.raw` . .
. .
\|/
------+------
|
/ /`}
</pre>
);
export default VanishedPerch;

@ -0,0 +1,15 @@
const WaywardGull = () => (
<pre
aria-hidden="true"
className="font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0 placeholder-wayward-gull-drift"
>
{String.raw` __
___ / _)>
\ \_/ /
\ /
'---'
. . .`}
</pre>
);
export default WaywardGull;

@ -38,9 +38,10 @@ describe("<Placeholder>", () => {
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(<Placeholder variant="empty" />);
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', () => {

@ -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", () => {

Loading…
Cancel
Save