feat(frontend): breakable grass block on the 404 page

Implements the last unbuilt easter egg (PLAN Task 11 #5, TODO «Пасхалки» #5): the
404 used to show static ASCII art; now a grass block drops into a dusk world,
cracks over three clicks, then bursts into square debris and respawns. Keyboard
accessible (a real button), and under prefers-reduced-motion the fall and burst
are skipped while the crack stages still work. Replaces the NOT_FOUND_ART pane;
i18n adds notFound.blockAria/blockHint (ru+en).
This commit is contained in:
loki5512344 2026-09-28 13:51:00 +02:00
parent 07b3f55bee
commit 904c724c9f
Signed by: boba
GPG key ID: 253067914055423B
6 changed files with 201 additions and 4 deletions

View file

@ -1,15 +1,14 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router' import { Link } from 'react-router'
import { NOT_FOUND_ART } from '../../shared/easter/art.generated' import { BreakableBlock } from '../../shared/easter/BreakableBlock'
import { PageTitle } from '../../shared/layout/PageTitle' import { PageTitle } from '../../shared/layout/PageTitle'
export default function NotFoundPage() { export default function NotFoundPage() {
const { t } = useTranslation('common') const { t } = useTranslation('common')
return ( return (
<section className="flex flex-1 flex-col items-start gap-6 py-16"> <section className="flex flex-1 flex-col items-start gap-6 py-16">
<pre aria-hidden="true" className="w-fit max-w-full overflow-x-auto rounded-panel border border-white/10 bg-black/30 p-4 font-code text-xs leading-tight text-ice"> <BreakableBlock />
{`${NOT_FOUND_ART}\n`}{t('notFound.console')} <p className="max-w-md text-sm text-frost">{t('notFound.blockHint')}</p>
</pre>
<PageTitle <PageTitle
lead={ lead={
<> <>

View file

@ -0,0 +1,71 @@
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import './block.css'
/** Clicks needed before the block shatters: two crack stages then the burst. */
const BREAK_AT = 3
type Particle = { id: number; dx: string; dy: string }
/**
* `/` 404 easter egg: a grass block drops into a dusk world, cracks over a few clicks,
* then bursts into square debris and respawns. Under reduced motion it skips the fall and
* the burst — clicking still walks the crack stages and resets.
*/
export function BreakableBlock() {
const { t } = useTranslation('common')
const reduced = useReducedMotion()
const [cracks, setCracks] = useState(0)
const [particles, setParticles] = useState<Particle[]>([])
const [cycle, setCycle] = useState(0) // bump to replay the drop after a burst
function burst() {
setParticles(
Array.from({ length: 10 }, (_, i) => ({
id: i,
dx: `${(i - 4.5) * 16}px`,
dy: `${-40 - (i % 3) * 24}px`,
})),
)
setCracks(0)
setCycle((c) => c + 1)
window.setTimeout(() => setParticles([]), 500)
}
function crack() {
const next = cracks + 1
if (next >= BREAK_AT) {
burst()
return
}
setCracks(next)
}
return (
<div className="lv-block-world">
<motion.span
key={cycle}
className="lv-block-wrap"
initial={reduced ? false : { y: -220, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={reduced ? { duration: 0 } : { type: 'spring', stiffness: 320, damping: 12 }}
>
<button type="button" className="lv-block" aria-label={t('notFound.blockAria')} onClick={crack}>
{cracks > 0 && <span className="lv-block-crack" data-stage={String(Math.min(cracks, 2))} aria-hidden="true" />}
</button>
</motion.span>
<AnimatePresence>
{particles.map((p) => (
<motion.span
key={`${cycle}-${p.id}`}
className="lv-block-particle"
style={{ ['--dx' as string]: p.dx, ['--dy' as string]: p.dy }}
exit={{ opacity: 0 }}
/>
))}
</AnimatePresence>
</div>
)
}

View file

@ -0,0 +1,87 @@
/* 404 easter egg: a dusk world with a grass block that falls, cracks and bursts. */
.lv-block-world {
position: relative;
display: grid;
place-items: end center;
width: 100%;
max-width: 420px;
height: 220px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: var(--radius-panel, 16px);
background: linear-gradient(180deg, #10162e 0%, #1a2340 55%, #24314f 100%);
}
.lv-block-wrap {
margin-bottom: 26px;
}
/* The block itself: grass crown over dirt, pixel-inset edges like a Minecraft tile. */
.lv-block {
position: relative;
display: block;
width: 84px;
height: 84px;
padding: 0;
border: 0;
cursor: pointer;
image-rendering: pixelated;
background: linear-gradient(#6c4b2a 0, #6c4b2a 100%);
box-shadow:
inset 0 0 0 3px rgb(0 0 0 / 0.22),
inset 0 -10px 0 rgb(0 0 0 / 0.12);
}
.lv-block::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 30%;
background: linear-gradient(#7ede68 0, #4f9c42 100%);
box-shadow: inset 0 -3px 0 rgb(0 0 0 / 0.18);
}
.lv-block:focus-visible {
outline: 2px solid var(--color-ice, #5cc8e7);
outline-offset: 3px;
}
/* Crack overlay: dark jagged strokes revealed stage by stage. */
.lv-block-crack {
position: absolute;
inset: 0;
pointer-events: none;
background-repeat: no-repeat;
background-image:
linear-gradient(65deg, transparent 47%, rgb(0 0 0 / 0.6) 48% 52%, transparent 53%),
linear-gradient(-40deg, transparent 44%, rgb(0 0 0 / 0.6) 45% 49%, transparent 50%),
linear-gradient(15deg, transparent 51%, rgb(0 0 0 / 0.6) 52% 55%, transparent 56%);
}
.lv-block-crack[data-stage='1'] { background-image: linear-gradient(65deg, transparent 47%, rgb(0 0 0 / 0.6) 48% 52%, transparent 53%); }
.lv-block-crack[data-stage='2'] {
background-image:
linear-gradient(65deg, transparent 47%, rgb(0 0 0 / 0.6) 48% 52%, transparent 53%),
linear-gradient(-40deg, transparent 44%, rgb(0 0 0 / 0.6) 45% 49%, transparent 50%);
}
/* Square debris that flies out and fades when the block bursts. */
.lv-block-particle {
position: absolute;
left: 50%;
bottom: 26px;
width: 10px;
height: 10px;
background: #4f9c42;
pointer-events: none;
animation: lv-block-burst 0.45s ease-out forwards;
}
.lv-block-particle:nth-child(even) { background: #6c4b2a; }
@keyframes lv-block-burst {
to {
transform: translate(var(--dx), var(--dy)) scale(0.2);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.lv-block-wrap { margin-bottom: 26px; }
.lv-block-particle { display: none; }
}

View file

@ -0,0 +1,36 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { BreakableBlock } from '../BreakableBlock'
import { renderApp } from '../../../test/render'
// motion's useReducedMotion reads matchMedia, which jsdom lacks.
vi.stubGlobal('matchMedia', (query: string) => ({
matches: !query.includes('reduce'),
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}))
test('a block drops in and cracks over three clicks, then bursts and respawns', async () => {
const user = userEvent.setup()
renderApp([{ path: '/', element: <BreakableBlock /> }])
// The block remounts (new key) on respawn, so re-query it before each click.
const block = () => screen.getByRole('button', { name: 'Сломать блок' })
expect(block().querySelector('.lv-block-crack')).toBeNull()
await user.click(block())
expect(block().querySelector('.lv-block-crack')).toHaveAttribute('data-stage', '1')
await user.click(block())
expect(block().querySelector('.lv-block-crack')).toHaveAttribute('data-stage', '2')
// Third click shatters: cracks clear (respawn) and debris spawns.
await user.click(block())
expect(block().querySelector('.lv-block-crack')).toBeNull()
expect(document.querySelectorAll('.lv-block-particle').length).toBeGreaterThan(0)
})

View file

@ -20,6 +20,8 @@ export const commonEn = {
body: 'This link may be outdated.', body: 'This link may be outdated.',
home: 'Go home', home: 'Go home',
console: '> chunk not found. Try again.', console: '> chunk not found. Try again.',
blockAria: 'Break the block',
blockHint: 'Click the block — it cracks and bursts, just like in the game.',
}, },
shareCode: { shareCode: {
copy: 'Copy', copy: 'Copy',

View file

@ -18,6 +18,8 @@ export const commonRu = {
body: 'Возможно, ссылка устарела.', body: 'Возможно, ссылка устарела.',
home: 'На главную', home: 'На главную',
console: '> chunk not found. Попробуй ещё раз.', console: '> chunk not found. Попробуй ещё раз.',
blockAria: 'Сломать блок',
blockHint: 'Кликни по блоку — он треснет и рассыплется, как в игре.',
}, },
shareCode: { shareCode: {
copy: 'Скопировать', copy: 'Скопировать',