Polish museum path puzzle UX for homelab testing.

Add celebration overlay with Ted image, puzzle sounds, larger artifact thumbnails, and fix desktop artifact tray scrolling.
This commit is contained in:
2026-06-11 10:10:05 -04:00
parent 7c68aa3bd3
commit 89af525f22
8 changed files with 196 additions and 40 deletions
+1
View File
@@ -15,6 +15,7 @@ dist-ssr
# Local media copies (generated by scripts/setup-dev-media.sh) # Local media copies (generated by scripts/setup-dev-media.sh)
public/media/unlock.mp4 public/media/unlock.mp4
public/media/artifact-room-map.jpg public/media/artifact-room-map.jpg
public/media/ted-celebrate.png
public/media/artifacts/ public/media/artifacts/
public/media/celebration-pop.mp3 public/media/celebration-pop.mp3
public/media/celebration-horn.mp3 public/media/celebration-horn.mp3
+1
View File
@@ -1,6 +1,7 @@
{ {
"unlockVideoUrl": "/media/unlock.mp4", "unlockVideoUrl": "/media/unlock.mp4",
"mapImageUrl": "/media/artifact-room-map.jpg", "mapImageUrl": "/media/artifact-room-map.jpg",
"tedCelebrateImageUrl": "/media/ted-celebrate.png",
"incorrectUrl": "/media/incorrect.mp3", "incorrectUrl": "/media/incorrect.mp3",
"unlockConfirmUrl": "/media/unlock-confirm.mp3", "unlockConfirmUrl": "/media/unlock-confirm.mp3",
"celebrationPopUrl": "/media/celebration-pop.mp3", "celebrationPopUrl": "/media/celebration-pop.mp3",
+8
View File
@@ -26,6 +26,14 @@ cp "$INCORRECT_SOURCE" "$DEST_DIR/incorrect.mp3"
cp "$CONFIRM_SOURCE" "$DEST_DIR/unlock-confirm.mp3" cp "$CONFIRM_SOURCE" "$DEST_DIR/unlock-confirm.mp3"
python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST_DIR/artifact-room-map.jpg" python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST_DIR/artifact-room-map.jpg"
python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST_DIR/artifacts" python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST_DIR/artifacts"
TED_CELEBRATE_SOURCE="${TED_CELEBRATE_SOURCE:-$SOURCE_DIR/ted-celebrate.png}"
if [[ -f "$TED_CELEBRATE_SOURCE" ]]; then
cp "$TED_CELEBRATE_SOURCE" "$DEST_DIR/ted-celebrate.png"
elif [[ -f "$ROOT/public/media/ted-celebrate.png" ]]; then
cp "$ROOT/public/media/ted-celebrate.png" "$DEST_DIR/ted-celebrate.png"
else
echo "Warning: ted-celebrate.png not found; puzzle 2 celebration image will be missing" >&2
fi
cp "$ROOT/public/media/config.json.example" "$DEST_DIR/config.json" cp "$ROOT/public/media/config.json.example" "$DEST_DIR/config.json"
echo "Copied media to $DEST_DIR/" echo "Copied media to $DEST_DIR/"
+9
View File
@@ -26,10 +26,19 @@ cp "$INCORRECT_SOURCE" "$DEST/incorrect.mp3"
cp "$CONFIRM_SOURCE" "$DEST/unlock-confirm.mp3" cp "$CONFIRM_SOURCE" "$DEST/unlock-confirm.mp3"
python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST/artifact-room-map.jpg" python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST/artifact-room-map.jpg"
python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST/artifacts" python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST/artifacts"
TED_CELEBRATE_SOURCE="${TED_CELEBRATE_SOURCE:-$SOURCE_DIR/ted-celebrate.png}"
if [[ -f "$TED_CELEBRATE_SOURCE" ]]; then
cp "$TED_CELEBRATE_SOURCE" "$DEST/ted-celebrate.png"
elif [[ -f "$ROOT/public/media/ted-celebrate.png" ]]; then
cp "$ROOT/public/media/ted-celebrate.png" "$DEST/ted-celebrate.png"
else
echo "Warning: ted-celebrate.png not found; puzzle 2 celebration image will be missing" >&2
fi
cat >"$DEST/config.json" <<'EOF' cat >"$DEST/config.json" <<'EOF'
{ {
"unlockVideoUrl": "/media/unlock.mp4", "unlockVideoUrl": "/media/unlock.mp4",
"mapImageUrl": "/media/artifact-room-map.jpg", "mapImageUrl": "/media/artifact-room-map.jpg",
"tedCelebrateImageUrl": "/media/ted-celebrate.png",
"incorrectUrl": "/media/incorrect.mp3", "incorrectUrl": "/media/incorrect.mp3",
"unlockConfirmUrl": "/media/unlock-confirm.mp3", "unlockConfirmUrl": "/media/unlock-confirm.mp3",
"celebrationPopUrl": "/media/celebration-pop.mp3", "celebrationPopUrl": "/media/celebration-pop.mp3",
+128 -25
View File
@@ -487,10 +487,15 @@
.artifact-bank__grid { .artifact-bank__grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(8.75rem, 1fr));
gap: 0.65rem; gap: 0.75rem;
max-height: 28rem; }
overflow-y: auto;
@media (max-width: 52rem) {
.artifact-bank__grid {
max-height: 28rem;
overflow-y: auto;
}
} }
.path-slots { .path-slots {
@@ -503,8 +508,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.65rem;
min-height: 4.5rem; min-height: 5.5rem;
padding: 0.5rem 0.65rem; padding: 0.55rem 0.75rem;
background: linear-gradient(180deg, #2a2218 0%, #1f1a14 100%); background: linear-gradient(180deg, #2a2218 0%, #1f1a14 100%);
border: 2px solid #4a3f32; border: 2px solid #4a3f32;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -561,7 +566,7 @@
} }
.artifact-card--overlay { .artifact-card--overlay {
width: 6.5rem; width: 8.75rem;
opacity: 0.95; opacity: 0.95;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
cursor: grabbing; cursor: grabbing;
@@ -575,8 +580,8 @@
} }
.artifact-card__image { .artifact-card__image {
width: 3.25rem; width: 5rem;
height: 3.25rem; height: 5rem;
object-fit: contain; object-fit: contain;
flex-shrink: 0; flex-shrink: 0;
background: #fff; background: #fff;
@@ -584,12 +589,17 @@
} }
.path-slot .artifact-card__image { .path-slot .artifact-card__image {
width: 2.75rem; width: 4rem;
height: 2.75rem; height: 4rem;
}
.artifact-card--overlay .artifact-card__image {
width: 5rem;
height: 5rem;
} }
.artifact-card__name { .artifact-card__name {
font-size: 0.78rem; font-size: 0.85rem;
line-height: 1.25; line-height: 1.25;
color: #f4e4bc; color: #f4e4bc;
text-align: center; text-align: center;
@@ -605,17 +615,110 @@
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.museum-path__success { .museum-path-panel--fade-out {
margin: 0.25rem 0 0; opacity: 0;
padding: 0.85rem 1.25rem; transform: scale(0.98);
font-family: Cinzel, Georgia, serif; pointer-events: none;
font-size: 1.1rem; transition:
font-weight: 600; opacity 0.65s ease-out,
letter-spacing: 0.04em; transform 0.65s ease-out;
color: #e8c547; }
text-align: center;
background: rgba(92, 68, 28, 0.5); .museum-path-celebration {
border: 1px solid rgba(201, 162, 39, 0.55); position: fixed;
border-radius: 0.5rem; inset: 0;
animation: error-pop 0.25s ease-out; z-index: 200;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.museum-path-celebration--fade-in {
opacity: 0;
animation: museum-celebration-in 0.9s ease-out 0.35s forwards;
}
@keyframes museum-celebration-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.museum-path-celebration__bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
}
.museum-path-celebration__scrim {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(26, 21, 16, 0.35) 0%,
rgba(26, 21, 16, 0.55) 45%,
rgba(26, 21, 16, 0.72) 100%
);
}
.museum-path-celebration__content {
position: relative;
z-index: 1;
max-width: 36rem;
margin: 0 1.5rem;
padding: 2rem 2.25rem;
text-align: center;
background: rgba(45, 36, 25, 0.72);
border: 3px solid #c9a227;
border-radius: 1rem;
box-shadow:
inset 0 0 0 1px rgba(232, 197, 71, 0.35),
0 16px 48px rgba(0, 0, 0, 0.5);
animation: museum-celebration-content-in 0.8s ease-out 0.7s both;
}
@keyframes museum-celebration-content-in {
from {
opacity: 0;
transform: translateY(1.25rem) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.museum-path-celebration__eyebrow {
margin: 0 0 0.65rem;
font-family: Cinzel, Georgia, serif;
font-size: clamp(1rem, 2.5vw, 1.2rem);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #e8c547;
}
.museum-path-celebration__title {
margin: 0 0 0.85rem;
font-family: Cinzel, Georgia, serif;
font-size: clamp(2rem, 6vw, 3rem);
font-weight: 700;
line-height: 1.15;
letter-spacing: 0.04em;
color: #f4e4bc;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.45);
}
.museum-path-celebration__subtitle {
margin: 0;
font-size: clamp(1rem, 2.5vw, 1.15rem);
line-height: 1.45;
color: #c9b896;
} }
+22
View File
@@ -0,0 +1,22 @@
interface MuseumPathCelebrationProps {
imageUrl: string;
}
export function MuseumPathCelebration({ imageUrl }: MuseumPathCelebrationProps) {
return (
<div className="museum-path-celebration museum-path-celebration--fade-in" role="status">
<img
className="museum-path-celebration__bg"
src={encodeURI(imageUrl)}
alt=""
aria-hidden="true"
/>
<div className="museum-path-celebration__scrim" aria-hidden="true" />
<div className="museum-path-celebration__content">
<p className="museum-path-celebration__eyebrow">You did it!</p>
<h1 className="museum-path-celebration__title">Ted escaped the museum!</h1>
<p className="museum-path-celebration__subtitle">You found the right path through the displays.</p>
</div>
</div>
);
}
+23 -15
View File
@@ -13,12 +13,15 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { playCelebrationSounds, playIncorrectSound } from './mediaSounds';
import { CORRECT_PATH, SLOT_COUNT } from './puzzle2Config'; import { CORRECT_PATH, SLOT_COUNT } from './puzzle2Config';
import { import {
artifactImageUrl, artifactImageUrl,
useArtifactManifest, useArtifactManifest,
type ArtifactEntry, type ArtifactEntry,
} from './useArtifactManifest'; } from './useArtifactManifest';
import { MuseumPathCelebration } from './MuseumPathCelebration';
import { useMediaConfig } from './useMediaConfig';
type DropZoneId = 'bank' | `slot-${number}`; type DropZoneId = 'bank' | `slot-${number}`;
@@ -153,6 +156,7 @@ function PathSlot({ index, artifact }: { index: number; artifact: ArtifactEntry
} }
export function MuseumPathPuzzle() { export function MuseumPathPuzzle() {
const mediaConfig = useMediaConfig();
const manifest = useArtifactManifest(); const manifest = useArtifactManifest();
const [slots, setSlots] = useState<(string | null)[]>(() => Array(SLOT_COUNT).fill(null)); const [slots, setSlots] = useState<(string | null)[]>(() => Array(SLOT_COUNT).fill(null));
const [activeSlug, setActiveSlug] = useState<string | null>(null); const [activeSlug, setActiveSlug] = useState<string | null>(null);
@@ -214,12 +218,14 @@ export function MuseumPathPuzzle() {
if (phase !== 'playing') return; if (phase !== 'playing') return;
if (!isPathCorrect(slots)) { if (!isPathCorrect(slots)) {
playIncorrectSound(mediaConfig.incorrectUrl);
triggerJiggle(); triggerJiggle();
return; return;
} }
setShowError(false); setShowError(false);
setPhase('solved'); setPhase('solved');
playCelebrationSounds(mediaConfig);
const confettiColors = ['#e8c547', '#c9a227', '#f4e4bc', '#d4a84b']; const confettiColors = ['#e8c547', '#c9a227', '#f4e4bc', '#d4a84b'];
const duration = 2000; const duration = 2000;
@@ -245,7 +251,7 @@ export function MuseumPathPuzzle() {
} }
}; };
frame(); frame();
}, [phase, slots, triggerJiggle]); }, [mediaConfig, phase, slots, triggerJiggle]);
if (!manifest) { if (!manifest) {
return ( return (
@@ -261,7 +267,14 @@ export function MuseumPathPuzzle() {
return ( return (
<div className="museum-path-scene"> <div className="museum-path-scene">
<div className="sarcophagus-panel museum-path-panel"> {phase === 'solved' && (
<MuseumPathCelebration imageUrl={mediaConfig.tedCelebrateImageUrl} />
)}
<div
className={`sarcophagus-panel museum-path-panel ${phase === 'solved' ? 'museum-path-panel--fade-out' : ''}`}
aria-hidden={phase === 'solved'}
>
<header className="lock-header museum-path__header"> <header className="lock-header museum-path__header">
<h1>Museum Path</h1> <h1>Museum Path</h1>
<p className="lock-subtitle"> <p className="lock-subtitle">
@@ -296,19 +309,14 @@ export function MuseumPathPuzzle() {
</p> </p>
)} )}
{phase === 'solved' ? ( <button
<p className="museum-path__success" role="status"> type="button"
You found the path! Ted escaped the museum! className={`unlock-btn museum-path__check ${jiggle ? 'unlock-btn--jiggle' : ''}`}
</p> onClick={handleCheckPath}
) : ( disabled={phase === 'solved'}
<button >
type="button" CHECK PATH
className={`unlock-btn museum-path__check ${jiggle ? 'unlock-btn--jiggle' : ''}`} </button>
onClick={handleCheckPath}
>
CHECK PATH
</button>
)}
</div> </div>
</div> </div>
); );
+4
View File
@@ -1,6 +1,7 @@
export interface MediaConfig { export interface MediaConfig {
unlockVideoUrl: string; unlockVideoUrl: string;
mapImageUrl: string; mapImageUrl: string;
tedCelebrateImageUrl: string;
incorrectUrl: string; incorrectUrl: string;
unlockConfirmUrl: string; unlockConfirmUrl: string;
celebrationPopUrl: string; celebrationPopUrl: string;
@@ -11,6 +12,7 @@ export interface MediaConfig {
export const DEFAULT_MEDIA_CONFIG: MediaConfig = { export const DEFAULT_MEDIA_CONFIG: MediaConfig = {
unlockVideoUrl: '/media/unlock.mp4', unlockVideoUrl: '/media/unlock.mp4',
mapImageUrl: '/media/artifact-room-map.jpg', mapImageUrl: '/media/artifact-room-map.jpg',
tedCelebrateImageUrl: '/media/ted-celebrate.png',
incorrectUrl: '/media/incorrect.mp3', incorrectUrl: '/media/incorrect.mp3',
unlockConfirmUrl: '/media/unlock-confirm.mp3', unlockConfirmUrl: '/media/unlock-confirm.mp3',
celebrationPopUrl: '/media/celebration-pop.mp3', celebrationPopUrl: '/media/celebration-pop.mp3',
@@ -23,6 +25,8 @@ export function mergeMediaConfig(partial: Partial<MediaConfig> | null): MediaCon
return { return {
unlockVideoUrl: partial.unlockVideoUrl ?? DEFAULT_MEDIA_CONFIG.unlockVideoUrl, unlockVideoUrl: partial.unlockVideoUrl ?? DEFAULT_MEDIA_CONFIG.unlockVideoUrl,
mapImageUrl: partial.mapImageUrl ?? DEFAULT_MEDIA_CONFIG.mapImageUrl, mapImageUrl: partial.mapImageUrl ?? DEFAULT_MEDIA_CONFIG.mapImageUrl,
tedCelebrateImageUrl:
partial.tedCelebrateImageUrl ?? DEFAULT_MEDIA_CONFIG.tedCelebrateImageUrl,
incorrectUrl: partial.incorrectUrl ?? DEFAULT_MEDIA_CONFIG.incorrectUrl, incorrectUrl: partial.incorrectUrl ?? DEFAULT_MEDIA_CONFIG.incorrectUrl,
unlockConfirmUrl: partial.unlockConfirmUrl ?? DEFAULT_MEDIA_CONFIG.unlockConfirmUrl, unlockConfirmUrl: partial.unlockConfirmUrl ?? DEFAULT_MEDIA_CONFIG.unlockConfirmUrl,
celebrationPopUrl: partial.celebrationPopUrl ?? DEFAULT_MEDIA_CONFIG.celebrationPopUrl, celebrationPopUrl: partial.celebrationPopUrl ?? DEFAULT_MEDIA_CONFIG.celebrationPopUrl,