feat(frontend): add pixel bird tilemaps

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

@ -1,120 +0,0 @@
/*
* Animations for <Placeholder> piece components.
*
* Keyframes are wrapped in a prefers-reduced-motion guard so users who opt
* out of motion see static ASCII art.
*/
@media (prefers-reduced-motion: no-preference) {
.placeholder-perched-finch-bob {
animation: placeholder-gentle-bob 3.4s 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-gentle-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes placeholder-soft-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}
@keyframes placeholder-quick-flutter {
0%,
100% {
transform: translate(0, 0);
}
50% {
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,77 +0,0 @@
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 PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound";
export interface AsciiPiece {
id: string;
variant: PlaceholderVariant;
credit: string;
Component: ComponentType;
}
export const ASCII_POOL: AsciiPiece[] = [
{
id: "memos-perched-finch",
variant: "empty",
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: "memos-busy-hummingbird",
variant: "loading",
credit: "Memos original ASCII art",
Component: BusyHummingbird,
},
{
id: "memos-searching-owl",
variant: "noResults",
credit: "Memos original ASCII art",
Component: SearchingOwl,
},
{
id: "memos-curious-crow",
variant: "noResults",
credit: "Memos original ASCII art",
Component: CuriousCrow,
},
{
id: "memos-wayward-gull",
variant: "notFound",
credit: "Memos original ASCII art",
Component: WaywardGull,
},
{
id: "memos-vanished-perch",
variant: "notFound",
credit: "Memos original ASCII art",
Component: VanishedPerch,
},
];
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)];
}

@ -1,8 +1,7 @@
import { type ReactNode, useMemo } from "react";
import { type ReactNode, useState } from "react";
import { cn } from "@/lib/utils";
import { type PlaceholderVariant, pickPiece } from "./ascii-pool";
import { DEFAULT_MESSAGES } from "./messages";
import "./Placeholder.css";
import { DEFAULT_MESSAGES, type PlaceholderVariant } from "./messages";
import { pickTileSprite } from "./tileSprites";
interface PlaceholderProps {
variant: PlaceholderVariant;
@ -11,11 +10,17 @@ interface PlaceholderProps {
className?: string;
}
const TILE_SIZE = 32;
const DISPLAY_SCALE = 2;
const DISPLAY_SIZE = TILE_SIZE * DISPLAY_SCALE;
const Placeholder = ({ variant, message, children, className }: PlaceholderProps) => {
const piece = useMemo(() => pickPiece(variant), [variant]);
const PieceComponent = piece.Component;
const [sprite] = useState(pickTileSprite);
const resolvedMessage = message ?? DEFAULT_MESSAGES[variant];
const isLoading = variant === "loading";
const stripWidth = sprite.frameWidth * sprite.frames;
const displayStripWidth = stripWidth * DISPLAY_SCALE;
const displayStripHeight = sprite.frameHeight * DISPLAY_SCALE;
return (
<div
@ -23,7 +28,45 @@ 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)}
>
<PieceComponent />
<style>{`
@keyframes placeholder-tile-strip {
from { transform: translateX(0); }
to { transform: translateX(-${displayStripWidth}px); }
}
@media (prefers-reduced-motion: reduce) {
[data-placeholder-strip] {
animation: none !important;
transform: translateX(0) !important;
}
}
`}</style>
<div
aria-hidden="true"
data-testid="placeholder-sprite"
className="relative shrink-0"
style={{ width: DISPLAY_SIZE, height: DISPLAY_SIZE, overflow: "hidden" }}
>
<img
data-placeholder-strip=""
src={sprite.src}
alt=""
width={stripWidth}
height={sprite.frameHeight}
draggable={false}
style={{
display: "block",
width: displayStripWidth,
height: displayStripHeight,
maxWidth: "none",
imageRendering: "pixelated",
animationName: "placeholder-tile-strip",
animationDuration: `${sprite.duration}ms`,
animationTimingFunction: `steps(${sprite.frames})`,
animationIterationCount: "infinite",
}}
/>
</div>
<p className="mt-3 font-mono text-sm text-muted-foreground">{resolvedMessage}</p>
{children && <div className="mt-4">{children}</div>}
</div>

@ -1,10 +1,10 @@
import type { PlaceholderVariant } from "./ascii-pool";
// Future i18n: swap these for `t("placeholder.<variant>")` lookups via
// react-i18next without touching the component.
export const DEFAULT_MESSAGES: Record<PlaceholderVariant, string> = {
export const DEFAULT_MESSAGES = {
empty: "No memos yet",
loading: "Loading…",
noResults: "Nothing matches that search",
notFound: "This page flew the coop",
};
} as const;
export type PlaceholderVariant = keyof typeof DEFAULT_MESSAGES;

@ -1,14 +0,0 @@
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;

@ -1,15 +0,0 @@
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,48 @@
# Placeholder Bird Tilemaps
These SVGs are pixel-art tile strips for the placeholder component. They should read as small game sprites first, not as decorative illustrations.
## Canvas
- Each frame is 32 by 32 pixels.
- A strip width is `32 * frameCount`; the height stays 32.
- The bird should occupy most of the frame. Use the full height when the animal shape supports it.
- Keep a transparent background.
- Use `shape-rendering="crispEdges"` and integer pixel coordinates.
## Naming
- Start with the animal name, for example `Owl` or `Falcon`.
- Add the animation name when needed, for example `OwlBlink` or `FalconIdle`.
- Do not name assets after UI states or empty-state scenarios.
## Frame Count
Frame count is not fixed. Choose it from the animal and animation:
- short blink: usually 3-5 frames
- quiet idle: usually 4-6 frames
- hop or walk: usually 4-8 frames
- flying, diving, or large body motion: usually 6-10 frames
Avoid padding an animation with duplicate frames just to hit a standard count. A frame should change the pose, expression, feather shape, or weight.
## Shared Style
- Use a strong readable silhouette at 1x.
- Prefer chunky pixel clusters over isolated noisy pixels.
- Use a limited palette with one dark outline, one or two body colors, one highlight color, and one accent.
- Keep eyes readable. At this scale, a 2 by 2 eye or a 1 pixel highlight is often better than a single dark pixel.
- Match visual weight between animals. Different species can have different proportions, but the on-screen size should feel comparable.
## Animation
- Make idle motion local: breathing, wing settling, ear feather movement, tail flicks, head turns, and blinking.
- Avoid moving the entire sprite unless the action is hop, fly, recoil, or collapse.
- Preserve the animal identity in every frame. A blink frame should still clearly read as the same bird.
- Keep the first frame a stable readable pose because it is what appears in reduced-motion rendering.
## Current Assets
- `OwlBlink.svg`: five-frame blink/idle strip with breathing wings, blink, and ear-feather settle.
- `FalconIdle.svg`: four-frame idle strip with breathing, blink, alert head shift, and tail flick.

@ -0,0 +1,122 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="32" viewBox="0 0 128 32" role="img" aria-labelledby="title desc" shape-rendering="crispEdges">
<title id="title">Falcon idle pixel tileset</title>
<desc id="desc">A four-frame 32 by 32 pixel falcon idle animation strip.</desc>
<defs>
<style>
.outline { fill: #120f0d; }
.head { fill: #f2efe7; }
.head-shadow { fill: #d4cec2; }
.beak { fill: #e7a634; }
.beak-light { fill: #f1bf48; }
.beak-shadow { fill: #b56e25; }
.body { fill: #7a5943; }
.body-dark { fill: #4b362d; }
.feather { fill: #a77b58; }
.wing { fill: #6a4b39; }
.tail-tip { fill: #f2efe7; }
.claw { fill: #d9972d; }
.eye { fill: #cf482b; }
.eye-light { fill: #ffd069; }
</style>
<symbol id="falcon-head" viewBox="0 0 32 32">
<path class="outline" d="M14 2h10v1h1v12h2v3h-1v1h-4v-2h-3v-3h-3v-2h-2V9H5V8h1V6h5V4h3V2z" />
<path class="head" d="M15 4h8v1h1v10h2v2h-3v-2h-3v-3h-3v-2h-2V8h-4V6h4V4z" />
<rect class="head-shadow" x="16" y="14" width="3" height="2" />
<rect class="head-shadow" x="20" y="16" width="2" height="1" />
<rect class="outline" x="18" y="7" width="3" height="1" />
<rect class="eye" x="20" y="8" width="2" height="2" />
<rect class="eye-light" x="21" y="8" width="1" height="1" />
<path class="outline" d="M4 9h11v1h2v5h-1v2H6v3H4V9z" />
<path class="beak-light" d="M6 10h9v1h1v2H6v-3z" />
<path class="beak" d="M5 13h11v2h-1v1H6v3H5v-6z" />
<rect class="beak-shadow" x="6" y="15" width="8" height="1" />
</symbol>
<symbol id="falcon-head-alert" viewBox="0 0 32 32">
<use href="#falcon-head" x="-1" />
<rect class="outline" x="18" y="7" width="4" height="1" />
<rect class="eye" x="19" y="8" width="2" height="2" />
<rect class="eye-light" x="20" y="8" width="1" height="1" />
</symbol>
<symbol id="falcon-body" viewBox="0 0 32 32">
<path class="outline" d="M7 15h17v2h2v9h-2v3h-4v2H10v-2H7v-3H6v-9h1v-2z" />
<path class="body" d="M9 16h14v2h1v8h-2v2H12v-2H9V16z" />
<path class="wing" d="M7 17h3v9H8v-1H7v-8z" />
<path class="wing" d="M23 17h3v8h-1v1h-2v-9z" />
<path class="body-dark" d="M10 25h3v2h2v2h-5v-2H8v-2h2z" />
<rect class="body-dark" x="22" y="25" width="3" height="2" />
<rect class="feather" x="11" y="18" width="1" height="1" />
<rect class="feather" x="15" y="18" width="1" height="1" />
<rect class="feather" x="19" y="18" width="1" height="1" />
<rect class="feather" x="12" y="20" width="1" height="1" />
<rect class="feather" x="16" y="20" width="1" height="1" />
<rect class="feather" x="20" y="20" width="1" height="1" />
<rect class="feather" x="13" y="22" width="1" height="1" />
<rect class="feather" x="18" y="22" width="1" height="1" />
<rect class="feather" x="21" y="22" width="1" height="1" />
<rect class="outline" x="13" y="28" width="2" height="2" />
<rect class="outline" x="19" y="28" width="2" height="2" />
<path class="claw" d="M14 26h1v3h2v1h-5v-1h2v-3z" />
<path class="claw" d="M20 26h1v3h2v1h-5v-1h2v-3z" />
</symbol>
<symbol id="falcon-body-breathe" viewBox="0 0 32 32">
<use href="#falcon-body" />
<rect class="outline" x="7" y="16" width="3" height="1" />
<rect class="wing" x="7" y="18" width="3" height="8" />
<rect class="outline" x="23" y="16" width="3" height="1" />
<rect class="wing" x="23" y="18" width="3" height="7" />
<rect class="body" x="11" y="17" width="10" height="1" />
</symbol>
<symbol id="falcon-tail" viewBox="0 0 32 32">
<path class="outline" d="M23 23h3v2h3v1h2v3h-2v1h-5v-2h-2v-3h1v-2z" />
<path class="body-dark" d="M23 24h3v2h2v1h-4v-1h-1v-2z" />
<rect class="tail-tip" x="26" y="25" width="2" height="2" />
<rect class="tail-tip" x="24" y="27" width="2" height="1" />
</symbol>
<symbol id="falcon-tail-flick" viewBox="0 0 32 32">
<path class="outline" d="M23 22h3v2h3v1h2v3h-2v1h-5v-2h-2v-3h1v-2z" />
<path class="body-dark" d="M23 23h3v2h2v1h-4v-1h-1v-2z" />
<rect class="tail-tip" x="26" y="24" width="2" height="2" />
<rect class="tail-tip" x="24" y="26" width="2" height="1" />
</symbol>
<symbol id="falcon-idle-a" viewBox="0 0 32 32">
<use href="#falcon-tail" />
<use href="#falcon-body" />
<use href="#falcon-head" />
</symbol>
<symbol id="falcon-idle-b" viewBox="0 0 32 32">
<use href="#falcon-tail" />
<use href="#falcon-body-breathe" />
<use href="#falcon-head" />
</symbol>
<symbol id="falcon-idle-blink" viewBox="0 0 32 32">
<use href="#falcon-tail" />
<use href="#falcon-body" />
<use href="#falcon-head" />
<rect class="head" x="20" y="8" width="2" height="2" />
<rect class="outline" x="19" y="8" width="3" height="1" />
</symbol>
<symbol id="falcon-idle-c" viewBox="0 0 32 32">
<use href="#falcon-tail-flick" />
<use href="#falcon-body" />
<use href="#falcon-head-alert" />
</symbol>
</defs>
<use href="#falcon-idle-a" x="0" y="0" width="32" height="32" />
<use href="#falcon-idle-b" x="32" y="0" width="32" height="32" />
<use href="#falcon-idle-blink" x="64" y="0" width="32" height="32" />
<use href="#falcon-idle-c" x="96" y="0" width="32" height="32" />
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

@ -1,14 +0,0 @@
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;

@ -1,14 +0,0 @@
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,84 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="32" viewBox="0 0 160 32" role="img" aria-labelledby="title desc" shape-rendering="crispEdges">
<title id="title">Owl blink pixel tileset</title>
<desc id="desc">A five-frame 32 by 32 pixel owl idle and blink animation strip.</desc>
<defs>
<style>
.outline { fill: #181512; }
.body { fill: #70685d; }
.body-dark { fill: #4f493f; }
.wing { fill: #5f584f; }
.face { fill: #b8afa2; }
.face-dark { fill: #958c7f; }
.eye-light { fill: #f4ead5; }
.eye-dark { fill: #2f2a24; }
.beak { fill: #bf9a53; }
.claw { fill: #d8d1c4; }
</style>
<symbol id="owl-open" viewBox="0 0 32 32">
<path class="outline" d="M7 0h6v2h6V0h6v3h2v3h2v19h-2v4h-5v2H10v-2H5v-4H3V6h2V3h2V0z" />
<path class="body" d="M8 5h16v2h2v17h-2v3h-5v2h-6v-2H8v-3H6V7h2V5z" />
<path class="wing" d="M4 15h6v11H8v-1H5v-3H4v-7z" />
<path class="wing" d="M22 15h6v7h-1v3h-3v1h-2V15z" />
<rect class="face" x="7" y="7" width="8" height="11" />
<rect class="face" x="17" y="7" width="8" height="11" />
<rect class="face-dark" x="9" y="19" width="14" height="5" />
<rect class="eye-light" x="8" y="9" width="5" height="5" />
<rect class="eye-light" x="19" y="9" width="5" height="5" />
<rect class="eye-dark" x="10" y="10" width="2" height="3" />
<rect class="eye-dark" x="20" y="10" width="2" height="3" />
<rect class="beak" x="15" y="15" width="2" height="2" />
<rect class="beak" x="14" y="17" width="4" height="2" />
<rect class="outline" x="11" y="27" width="2" height="3" />
<rect class="outline" x="19" y="27" width="2" height="3" />
<rect class="claw" x="10" y="30" width="4" height="1" />
<rect class="claw" x="18" y="30" width="4" height="1" />
</symbol>
<symbol id="owl-breathe" viewBox="0 0 32 32">
<use href="#owl-open" />
<rect class="outline" x="3" y="16" width="1" height="6" />
<rect class="wing" x="4" y="16" width="6" height="10" />
<rect class="outline" x="28" y="16" width="1" height="6" />
<rect class="wing" x="22" y="16" width="6" height="10" />
<rect class="body" x="11" y="24" width="10" height="2" />
<rect class="body-dark" x="10" y="25" width="12" height="1" />
<rect class="claw" x="10" y="30" width="5" height="1" />
<rect class="claw" x="17" y="30" width="5" height="1" />
</symbol>
<symbol id="owl-half" viewBox="0 0 32 32">
<use href="#owl-breathe" />
<rect class="face" x="8" y="9" width="5" height="5" />
<rect class="face" x="19" y="9" width="5" height="5" />
<rect class="eye-dark" x="8" y="11" width="5" height="2" />
<rect class="eye-dark" x="19" y="11" width="5" height="2" />
</symbol>
<symbol id="owl-closed" viewBox="0 0 32 32">
<use href="#owl-breathe" />
<rect class="face" x="8" y="9" width="5" height="5" />
<rect class="face" x="19" y="9" width="5" height="5" />
<rect class="eye-dark" x="8" y="12" width="5" height="1" />
<rect class="eye-dark" x="19" y="12" width="5" height="1" />
</symbol>
<symbol id="owl-settle" viewBox="0 0 32 32">
<use href="#owl-open" />
<rect class="outline" x="6" y="1" width="2" height="3" />
<rect class="outline" x="24" y="1" width="2" height="3" />
<rect class="body-dark" x="4" y="21" width="1" height="2" />
<rect class="body-dark" x="27" y="21" width="1" height="2" />
<rect class="face-dark" x="11" y="20" width="10" height="1" />
</symbol>
</defs>
<use href="#owl-open" x="0" y="0" width="32" height="32" />
<use href="#owl-breathe" x="32" y="0" width="32" height="32" />
<use href="#owl-half" x="64" y="0" width="32" height="32" />
<use href="#owl-closed" x="96" y="0" width="32" height="32" />
<use href="#owl-settle" x="128" y="0" width="32" height="32" />
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@ -1,15 +0,0 @@
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;

@ -1,15 +0,0 @@
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;

@ -1,16 +0,0 @@
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;

@ -1,15 +0,0 @@
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;

@ -0,0 +1,34 @@
import FalconIdle from "./pieces/FalconIdle.svg?url";
import OwlBlink from "./pieces/OwlBlink.svg?url";
export interface TileSprite {
name: string;
src: string;
frameWidth: number;
frameHeight: number;
frames: number;
duration: number;
}
export const TILE_SPRITES: TileSprite[] = [
{
name: "OwlBlink",
src: OwlBlink,
frameWidth: 32,
frameHeight: 32,
frames: 5,
duration: 1500,
},
{
name: "FalconIdle",
src: FalconIdle,
frameWidth: 32,
frameHeight: 32,
frames: 4,
duration: 960,
},
];
export function pickTileSprite(): TileSprite {
return TILE_SPRITES[Math.floor(Math.random() * TILE_SPRITES.length)];
}

@ -0,0 +1,49 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PagedMemoList from "@/components/PagedMemoList";
vi.mock("@/hooks/useMemoQueries", () => ({
useInfiniteMemos: () => ({
data: { pages: [{ memos: [], nextPageToken: "" }] },
fetchNextPage: vi.fn(async () => undefined),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
useMemoFilterContext: () => ({ filters: [] }),
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => (key === "message.no-data" ? "No data found." : key),
}));
vi.mock("@/components/MemoContent/MentionResolutionContext", () => ({
MentionResolutionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/MemoFilters", () => ({
default: () => <div data-testid="memo-filters" />,
}));
vi.mock("@/components/MemoEditor", () => ({
default: () => <div data-testid="memo-editor" />,
}));
describe("<PagedMemoList>", () => {
it("uses the tile sprite Placeholder for the empty state", () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<PagedMemoList renderer={() => <div />} />
</QueryClientProvider>,
);
expect(screen.getByText("No data found.")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
});

@ -30,12 +30,27 @@ describe("<Placeholder>", () => {
expect(screen.queryByText(DEFAULT_MESSAGES.empty)).not.toBeInTheDocument();
});
it("renders the ASCII art inside a <pre> with aria-hidden", () => {
it("renders a 32px sprite tileset at a crisp 2x display scale", () => {
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);
const viewport = screen.getByTestId("placeholder-sprite");
const strip = viewport.firstElementChild;
expect(viewport).toHaveAttribute("aria-hidden", "true");
expect(viewport).toHaveStyle({
width: "64px",
height: "64px",
overflow: "hidden",
});
expect(strip).toHaveAttribute("src", expect.stringMatching(/(\.svg|data:image\/svg\+xml)/));
expect(strip).toHaveAttribute("width", expect.stringMatching(/^(128|160|192)$/));
expect(strip).toHaveAttribute("height", "32");
expect(["256px", "320px", "384px"]).toContain((strip as HTMLElement).style.width);
expect(["steps(4)", "steps(5)", "steps(6)"]).toContain((strip as HTMLElement).style.animationTimingFunction);
expect(strip).toHaveStyle({
height: "64px",
imageRendering: "pixelated",
});
expect(container.firstChild).toHaveClass("max-w-md");
});
it("does not render registry credit strings in the UI", () => {

@ -1,64 +1,33 @@
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";
const VARIANTS: PlaceholderVariant[] = ["empty", "loading", "noResults", "notFound"];
describe("ASCII_POOL integrity", () => {
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(2);
import { TILE_SPRITES, pickTileSprite } from "@/components/Placeholder/tileSprites";
import { DEFAULT_MESSAGES, type PlaceholderVariant } from "@/components/Placeholder/messages";
describe("TILE_SPRITES integrity", () => {
it("registers 32px by 32px sprite strips with animation-specific frame counts", () => {
expect(TILE_SPRITES.map((sprite) => sprite.name)).toEqual(["OwlBlink", "FalconIdle"]);
expect(TILE_SPRITES.map((sprite) => [sprite.name, sprite.frames])).toEqual([
["OwlBlink", 5],
["FalconIdle", 4],
]);
for (const sprite of TILE_SPRITES) {
expect(sprite.name).toMatch(/^[A-Z][A-Za-z]+(Idle|Hop|Blink|Drift|Flutter|Hover)$/);
expect(sprite.frameWidth).toBe(32);
expect(sprite.frameHeight).toBe(32);
expect(sprite.frames).toBeGreaterThanOrEqual(2);
expect(sprite.src).toMatch(/(\.svg|data:image\/svg\+xml)/);
}
});
it("uses unique ids", () => {
const ids = ASCII_POOL.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("uses non-empty credits for every piece", () => {
for (const piece of ASCII_POOL) {
expect(piece.credit.trim().length, `piece=${piece.id}`).toBeGreaterThan(0);
}
});
it("uses original Memos credits for bundled pieces", () => {
for (const piece of ASCII_POOL) {
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();
}
});
});
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 registered tile sprite from the pool", () => {
const sprite = pickTileSprite();
expect(TILE_SPRITES).toContain(sprite);
});
});
describe("DEFAULT_MESSAGES", () => {
it("provides a non-empty message for every variant", () => {
for (const variant of VARIANTS) {
for (const variant of Object.keys(DEFAULT_MESSAGES) as PlaceholderVariant[]) {
expect(DEFAULT_MESSAGES[variant], `variant=${variant}`).toBeTruthy();
expect(DEFAULT_MESSAGES[variant].trim().length).toBeGreaterThan(0);
}

Loading…
Cancel
Save