feat(frontend): add pixel bird tilemaps
This commit is contained in:
@@ -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";
|
||||
import { TILE_SPRITES, pickTileSprite } from "@/components/Placeholder/tileSprites";
|
||||
import { DEFAULT_MESSAGES, type PlaceholderVariant } from "@/components/Placeholder/messages";
|
||||
|
||||
const VARIANTS: PlaceholderVariant[] = ["empty", "loading", "noResults", "notFound"];
|
||||
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],
|
||||
]);
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user