diff --git a/README.md b/README.md index bc229a0b..8286fffd 100644 --- a/README.md +++ b/README.md @@ -117,10 +117,7 @@ The full list lives in [Features](docs/FEATURES.md). A member's ID card in the account wallet, in dark mode

ID wallet, dark mode. Tilt it, flip it, verify it.

- - The 404 page: a torn ticket and a golden retriever along the bottom -

404. This page isn't on the programme, but the golden retriever is.

- + diff --git a/app/globals.css b/app/globals.css index b31f5895..aa7ba88f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -390,38 +390,27 @@ transition: transform 520ms cubic-bezier(0.34, 1.4, 0.64, 1), opacity 320ms ease-out; } -/* 404 golden retriever (components/feedback/NotFoundPet.tsx). Motion is driven by data-state; - the reduced-motion rule above stills every loop. */ +/* Patch on the 404 page (components/feedback/NotFoundPet.tsx). Each direction is one sprite sheet of + the design system's eight-frame walk cycle (heading left uses the approved left-facing sheet; Patch + is never flipped). Frame N sits at N/7 of the sheet. Motion is driven by data-state, data-facing + and data-happy; the reduced-motion rule above stills every loop. */ .yy-pet { will-change: transform; } -.yy-pet-body { transition: transform 160ms ease-out; } -.yy-pet-leg { transform-box: fill-box; transform-origin: 50% 0; } -.yy-pet-tail { transform-box: fill-box; transform-origin: 100% 100%; animation: yy-pet-wag 1.4s ease-in-out infinite; } -.yy-pet-head { transform-box: fill-box; transform-origin: 30% 80%; } -.yy-pet-pupils { transform: translate(var(--look-x, 0px), var(--look-y, 0px)); transition: transform 120ms ease-out; } -.yy-pet-eyes { transform-box: fill-box; transform-origin: 50% 50%; animation: yy-pet-blink 5.2s infinite; } -.yy-pet-sleepy-eyes { opacity: 0; } - -.yy-pet[data-state="walk"] .yy-pet-leg-a, -.yy-pet[data-state="run"] .yy-pet-leg-a { animation: yy-pet-step 0.32s ease-in-out infinite alternate; } -.yy-pet[data-state="walk"] .yy-pet-leg-b, -.yy-pet[data-state="run"] .yy-pet-leg-b { animation: yy-pet-step 0.32s ease-in-out infinite alternate-reverse; } -.yy-pet[data-state="run"] .yy-pet-leg { animation-duration: 0.18s; } -.yy-pet[data-state="walk"] .yy-pet-torso, -.yy-pet[data-state="run"] .yy-pet-torso { animation: yy-pet-bob 0.32s ease-in-out infinite alternate; } -.yy-pet[data-state="run"] .yy-pet-torso { animation-duration: 0.18s; } -.yy-pet[data-state="run"] .yy-pet-tail { animation-duration: 0.3s; } -.yy-pet[data-state="sit"] .yy-pet-head { animation: yy-pet-tilt 4s ease-in-out infinite; } -.yy-pet[data-happy] .yy-pet-tail { animation: yy-pet-wag 0.18s ease-in-out infinite; } -.yy-pet[data-happy] .yy-pet-torso { animation: yy-pet-bob 0.22s ease-in-out 4 alternate; } - -.yy-pet[data-state="sleep"] .yy-pet-eyes { opacity: 0; animation: none; } -.yy-pet[data-state="sleep"] .yy-pet-sleepy-eyes { opacity: 1; } -.yy-pet[data-state="sleep"] .yy-pet-torso { animation: yy-pet-breathe 3s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; } -.yy-pet[data-state="sleep"] .yy-pet-tail { animation: none; } +.yy-pet-sprite { + aspect-ratio: 256 / 176; + width: 100%; + background: url("/brand/patch/patch-walk-right.webp") 0 0 / 800% 100% no-repeat; +} +.yy-pet[data-facing="left"] .yy-pet-sprite { background-image: url("/brand/patch/patch-walk-left.webp"); } +.yy-pet[data-state="walk"] .yy-pet-sprite { animation: yy-pet-walk 0.96s steps(8) infinite; } +.yy-pet[data-state="run"] .yy-pet-sprite { animation: yy-pet-walk 0.48s steps(8) infinite; } +/* Asleep or patted: frame 6, Patch's eyes closed happily as drawn on the walk sheet. */ +.yy-pet[data-state="sleep"] .yy-pet-sprite, +.yy-pet[data-happy] .yy-pet-sprite { animation: none; background-position: calc(500% / 7) 0; } +.yy-pet[data-happy] { animation: yy-pet-bounce 0.22s ease-in-out 4 alternate; } .yy-pet-zzz { position: absolute; - right: 4px; + right: 10%; top: -6px; font-family: var(--font-display); font-weight: 600; @@ -430,6 +419,7 @@ opacity: 0; pointer-events: none; } +.yy-pet[data-facing="left"] .yy-pet-zzz { right: auto; left: 10%; } .yy-pet[data-state="sleep"] .yy-pet-zzz { animation: yy-pet-zzz 2.8s ease-out infinite; } .yy-pet[data-state="sleep"] .yy-pet-zzz:nth-of-type(2) { animation-delay: 1.4s; font-size: 11px; } @@ -449,7 +439,7 @@ /* Fading paw prints in the muted ink. */ .yy-pet-paw { position: absolute; - bottom: 10px; + bottom: 6px; width: 9px; height: 7px; border-radius: 50% 50% 45% 45%; @@ -459,23 +449,8 @@ animation: yy-pet-paw 1.8s ease-out forwards; } -/* The floppy ear swings as the dog trots and flaps when patted; the tongue - comes out when it is happy. */ -.yy-pet-ear { transform-box: fill-box; transform-origin: 60% 8%; } -.yy-pet[data-state="walk"] .yy-pet-ear, -.yy-pet[data-state="run"] .yy-pet-ear { animation: yy-pet-flap 0.32s ease-in-out infinite alternate; } -.yy-pet[data-happy] .yy-pet-ear { animation: yy-pet-flap 0.2s ease-in-out 6 alternate; } -@keyframes yy-pet-flap { from { transform: rotate(-6deg); } to { transform: rotate(10deg); } } -.yy-pet-tongue { transform-box: fill-box; transform-origin: 50% 0; transform: scaleY(0); transition: transform 160ms ease-out; } -.yy-pet[data-happy] .yy-pet-tongue, -.yy-pet[data-state="run"] .yy-pet-tongue { transform: scaleY(1); } - -@keyframes yy-pet-step { from { transform: rotate(-16deg); } to { transform: rotate(16deg); } } -@keyframes yy-pet-bob { from { transform: translateY(0); } to { transform: translateY(-2.5px); } } -@keyframes yy-pet-wag { 0%, 100% { transform: rotate(-10deg); } 50% { transform: rotate(16deg); } } -@keyframes yy-pet-tilt { 0%, 70%, 100% { transform: rotate(0deg); } 80%, 90% { transform: rotate(-9deg); } } -@keyframes yy-pet-blink { 0%, 94%, 100% { transform: scaleY(1); } 96% { transform: scaleY(0.1); } } -@keyframes yy-pet-breathe { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(0.95); } } +@keyframes yy-pet-walk { from { background-position: 0 0; } to { background-position: calc(800% / 7) 0; } } +@keyframes yy-pet-bounce { from { translate: 0 0; } to { translate: 0 -3px; } } @keyframes yy-pet-zzz { 0% { opacity: 0; transform: translate(0, 0) scale(0.7); } 25% { opacity: 0.8; } 100% { opacity: 0; transform: translate(14px, -26px) scale(1.2); } } @keyframes yy-pet-heart { 0% { opacity: 0; transform: translateY(0) scale(0.6); } 20% { opacity: 1; } 100% { opacity: 0; transform: translateY(-34px) scale(1.15); } } @keyframes yy-pet-paw { 0% { opacity: 0.35; } 100% { opacity: 0; } } diff --git a/components/feedback/NotFoundPet.tsx b/components/feedback/NotFoundPet.tsx index 09c70af0..b95a22a8 100644 --- a/components/feedback/NotFoundPet.tsx +++ b/components/feedback/NotFoundPet.tsx @@ -5,24 +5,23 @@ import { useCallback, useEffect, useRef } from "react"; type PetState = "walk" | "run" | "sit" | "sleep"; const EDGE = 12; -/** Golden coat: Warm Yellow and Workshop Orange mixed 70/30; shading 50/50. */ -const COAT = "color-mix(in srgb, var(--sw-yellow) 70%, var(--sw-orange))"; -const SHADE = "color-mix(in srgb, var(--sw-yellow) 50%, var(--sw-orange))"; const WANDER_AFTER_MS = 4_500; const SLEEP_AFTER_MS = 15_000; /** - * A small decorative golden retriever that lives along the bottom of the 404 - * page. It runs toward the pointer (or a tap on touch screens), watches the - * pointer, leaves fading paw prints, wanders and naps when ignored, and wags, - * flops its ear, sticks its tongue out and sends up hearts when patted. - * Purely decorative: hidden from assistive tech and click-through (it never - * blocks page controls), and it sits still for reduced-motion users. + * Patch, the Space Works mascot, trotting along the bottom of the 404 page. Patch runs toward the + * pointer (or a tap on touch screens), leaves fading paw prints, wanders and naps when ignored, and + * closes its eyes happily and sends up hearts when patted. + * + * The artwork is the design system's walk cycle (SpaceWorks-Design `mascot/original/walk/` and the + * approved left-facing `walk-left/`), eight frames per direction in one sprite sheet each under + * `public/brand/patch/`. Patch is never flipped or redrawn here: heading left uses the left-facing + * sheet. Purely decorative: hidden from assistive tech and click-through (it never blocks page + * controls), and it sits still for reduced-motion users. */ export function NotFoundPet() { const layerRef = useRef(null); const petRef = useRef(null); - const bodyRef = useRef(null); const pat = useCallback(() => { const pet = petRef.current; @@ -31,7 +30,7 @@ export function NotFoundPet() { for (let i = 0; i < 3; i++) { const heart = document.createElement("span"); heart.className = "yy-pet-heart"; - heart.style.left = `${24 + i * 12}px`; + heart.style.left = `${30 + i * 14}%`; heart.style.animationDelay = `${i * 120}ms`; pet.appendChild(heart); window.setTimeout(() => heart.remove(), 1400); @@ -44,22 +43,19 @@ export function NotFoundPet() { useEffect(() => { const layer = layerRef.current; const pet = petRef.current; - const body = bodyRef.current; - if (!layer || !pet || !body) return; + if (!layer || !pet) return; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - const petWidth = () => pet.offsetWidth || 64; + const petWidth = () => pet.offsetWidth || 88; const maxX = () => Math.max(EDGE, window.innerWidth - petWidth() - EDGE); let x = reduceMotion ? maxX() - 8 : Math.min(maxX(), window.innerWidth * 0.18); let target = x; - let facing = 1; let state: PetState = "sit"; let lastInput = performance.now(); let nextWander = 0; let hop = 0; // 0..1 progress of a hop arc let hopping = false; let stepDistance = 0; - let pointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; let frame = 0; const setState = (next: PetState) => { @@ -67,26 +63,20 @@ export function NotFoundPet() { state = next; pet.dataset.state = next; }; + const face = (direction: "left" | "right") => { + if (pet.dataset.facing !== direction) pet.dataset.facing = direction; + }; const render = () => { const lift = hopping ? Math.sin(hop * Math.PI) * 46 : 0; pet.style.transform = `translate3d(${x}px, ${-lift}px, 0)`; - body.style.transform = `scaleX(${facing})`; - // Eyes look toward the pointer (mirrored when facing left). - const headX = x + petWidth() * (facing > 0 ? 0.72 : 0.28); - const headY = window.innerHeight - 52 - lift; - const dx = pointer.x - headX; - const dy = pointer.y - headY; - const length = Math.hypot(dx, dy) || 1; - pet.style.setProperty("--look-x", `${((dx / length) * 1.6 * facing).toFixed(2)}px`); - pet.style.setProperty("--look-y", `${((dy / length) * 1.4).toFixed(2)}px`); }; const dropPaw = () => { const paw = document.createElement("span"); paw.className = "yy-pet-paw"; paw.style.left = `${x + petWidth() / 2 + (Math.random() * 10 - 5)}px`; - paw.style.transform = `rotate(${facing > 0 ? 90 : -90}deg)`; + paw.style.transform = `rotate(${pet.dataset.facing === "left" ? -90 : 90}deg)`; layer.appendChild(paw); window.setTimeout(() => paw.remove(), 1800); }; @@ -102,12 +92,10 @@ export function NotFoundPet() { }; const onPointerMove = (event: PointerEvent) => { - pointer = { x: event.clientX, y: event.clientY }; if (event.pointerType === "mouse" || event.buttons > 0) goTo(event.clientX); else lastInput = performance.now(); }; const onPointerDown = (event: PointerEvent) => { - pointer = { x: event.clientX, y: event.clientY }; // The layer is click-through, so detect a pat by position instead of a click handler. const bounds = pet.getBoundingClientRect(); if (event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top - 8 && event.clientY <= bounds.bottom) { @@ -125,6 +113,7 @@ export function NotFoundPet() { }; pet.dataset.state = state; + face(reduceMotion ? "left" : "right"); render(); window.addEventListener("resize", onResize); if (reduceMotion) return () => window.removeEventListener("resize", onResize); @@ -151,7 +140,7 @@ export function NotFoundPet() { const speed = Math.min(Math.abs(dx) * 0.06, fast ? 7.5 : 3.2) * dt; const move = Math.sign(dx) * Math.max(0.6, speed); x += Math.abs(move) > Math.abs(dx) ? dx : move; - facing = dx > 0 ? 1 : -1; + face(dx > 0 ? "right" : "left"); setState(fast ? "run" : "walk"); stepDistance += Math.abs(move); if (stepDistance > 34) { @@ -184,77 +173,10 @@ export function NotFoundPet() { return (
-
+
z z -
- {/* A golden retriever drawn in brand colours: the coat is Warm Yellow and - Workshop Orange mixed 70/30 (50/50 in the shading), Warm Cream for the - snout and highlights, Deep Ink for lines, eyes and nose, and a Maker - Teal collar with a Citron tag. The tongue (Workshop Orange) shows when - the dog is happy. */} - - - {/* Feathery tail, hinged at the rump */} - - - - - {/* Legs with cream paws */} - {[ - { x: 18, side: "a" }, - { x: 28, side: "b" }, - { x: 41, side: "b" }, - { x: 51, side: "a" }, - ].map((leg) => ( - - - - - ))} - - - {/* Shaded back and a fluffy cream chest */} - - - - {/* Collar and tag */} - - - - {/* Snout, nose, mouth and tongue */} - - - - - {/* Floppy ear */} - - - - - - - - - - - - - - - - -
+
); diff --git a/docs/readme/screens/not-found.webp b/docs/readme/screens/not-found.webp deleted file mode 100644 index 61eb328b..00000000 Binary files a/docs/readme/screens/not-found.webp and /dev/null differ diff --git a/public/brand/patch/patch-walk-left.webp b/public/brand/patch/patch-walk-left.webp new file mode 100644 index 00000000..c10962a9 Binary files /dev/null and b/public/brand/patch/patch-walk-left.webp differ diff --git a/public/brand/patch/patch-walk-right.webp b/public/brand/patch/patch-walk-right.webp new file mode 100644 index 00000000..30835b37 Binary files /dev/null and b/public/brand/patch/patch-walk-right.webp differ