feat: Hall of Chaos easter egg -- HallOfChaosView + long-press trigger

Adds the Hall of Chaos overlay component (recipe blooper gallery with
static CSS tilts, chaos level counter, panel-local overlay) and wires
the 800ms long-press trigger on the Bloopers filter tab in
CommunityFeedPanel. Pairs with the backend /community/hall-of-chaos
endpoint and test added in Task 10.
This commit is contained in:
pyr0ball 2026-04-13 12:30:48 -07:00
parent f92ac7a509
commit 9246935fd7
2 changed files with 208 additions and 0 deletions

View file

@ -12,6 +12,9 @@
:class="['btn', 'tab-btn', activeFilter === f.id ? 'btn-primary' : 'btn-secondary']"
@click="setFilter(f.id)"
@keydown="onFilterKeydown"
@pointerdown="f.id === 'recipe_blooper' ? onBlooperPointerDown($event) : undefined"
@pointerup="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
@pointerleave="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
>{{ f.label }}</button>
</div>
@ -103,6 +106,12 @@
@published="onPlanPublished"
/>
<!-- Hall of Chaos easter egg: hold Bloopers tab for 800ms -->
<HallOfChaosView
v-if="showHallOfChaos"
@close="showHallOfChaos = false"
/>
</div>
</template>
@ -111,6 +120,7 @@ import { ref, onMounted } from 'vue'
import { useCommunityStore } from '../stores/community'
import CommunityPostCard from './CommunityPostCard.vue'
import PublishPlanModal from './PublishPlanModal.vue'
import HallOfChaosView from './HallOfChaosView.vue'
const emit = defineEmits<{
'plan-forked': [payload: { plan_id: number; week_start: string }]
@ -120,6 +130,22 @@ const store = useCommunityStore()
const activeFilter = ref('all')
const showPublishPlan = ref(false)
const showHallOfChaos = ref(false)
let blooperHoldTimer: ReturnType<typeof setTimeout> | null = null
function onBlooperPointerDown(_e: PointerEvent) {
blooperHoldTimer = setTimeout(() => {
showHallOfChaos.value = true
blooperHoldTimer = null
}, 800)
}
function onBlooperPointerCancel() {
if (blooperHoldTimer !== null) {
clearTimeout(blooperHoldTimer)
blooperHoldTimer = null
}
}
const filters = [
{ id: 'all', label: 'All' },

View file

@ -0,0 +1,182 @@
<template>
<div class="hall-of-chaos-overlay" role="dialog" aria-modal="true" aria-label="Hall of Chaos">
<!-- Header -->
<div class="chaos-header">
<h2 class="chaos-title">HALL OF CHAOS</h2>
<p class="chaos-subtitle text-sm">
Chaos Level: <span class="chaos-level">{{ chaosLevel }}</span>
</p>
<button
class="btn btn-secondary chaos-exit-btn"
aria-label="Exit Hall of Chaos"
@click="$emit('close')"
>
Escape the chaos
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="chaos-loading text-center text-secondary" aria-busy="true">
Assembling the chaos...
</div>
<!-- Error -->
<div v-else-if="error" class="chaos-empty text-center text-secondary" role="alert">
The chaos is temporarily indisposed.
</div>
<!-- Empty -->
<div v-else-if="posts.length === 0" class="chaos-empty text-center text-secondary">
<p>No bloopers yet. Be the first to make a glorious mistake.</p>
</div>
<!-- Blooper cards -->
<div v-else class="chaos-grid" aria-label="Blooper posts">
<article
v-for="(post, index) in posts"
:key="post.slug"
class="chaos-card"
:class="`chaos-card--tilt-${(index % 5) + 1}`"
:style="{ '--chaos-border-color': borderColors[index % borderColors.length] }"
>
<p class="chaos-card-author text-xs text-muted">{{ post.pseudonym }}</p>
<h3 class="chaos-card-title text-base font-semibold">{{ post.title }}</h3>
<p v-if="post.outcome_notes" class="chaos-card-notes text-sm text-secondary">
{{ post.outcome_notes }}
</p>
<p v-if="post.recipe_name" class="chaos-card-recipe text-xs text-muted mt-xs">
Recipe: {{ post.recipe_name }}
</p>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import api from '../services/api'
import type { CommunityPost } from '../stores/community'
defineEmits<{ close: [] }>()
const posts = ref<CommunityPost[]>([])
const chaosLevel = ref(0)
const loading = ref(true)
const error = ref(false)
// CSS custom property strings -- no hardcoded hex
const borderColors = [
'var(--color-warning)',
'var(--color-info)',
'var(--color-success)',
'var(--color-error)',
'var(--color-warning)',
]
onMounted(async () => {
try {
const response = await api.get<{ posts: CommunityPost[]; chaos_level: number }>(
'/community/hall-of-chaos'
)
posts.value = response.data.posts
chaosLevel.value = response.data.chaos_level
} catch {
error.value = true
} finally {
loading.value = false
}
})
</script>
<style scoped>
.hall-of-chaos-overlay {
position: absolute;
inset: 0;
z-index: 200;
background: var(--color-bg-primary);
overflow-y: auto;
padding: var(--spacing-md);
border-radius: var(--radius-lg);
}
.chaos-header {
text-align: center;
margin-bottom: var(--spacing-lg);
}
.chaos-title {
font-size: 2rem;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--color-warning);
margin: 0 0 var(--spacing-xs);
text-transform: uppercase;
}
.chaos-subtitle {
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-sm);
}
.chaos-level {
font-weight: 700;
color: var(--color-warning);
}
.chaos-exit-btn {
font-size: var(--font-size-xs);
}
.chaos-loading,
.chaos-empty {
padding: var(--spacing-xl);
}
.chaos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-md);
padding-bottom: var(--spacing-lg);
}
/* Static tilts applied once at render -- not animations, no reduced-motion concern */
.chaos-card {
background: var(--color-bg-card);
border: 2px solid var(--chaos-border-color, var(--color-border));
border-radius: var(--radius-lg);
padding: var(--spacing-md);
}
.chaos-card--tilt-1 { transform: rotate(-3deg); }
.chaos-card--tilt-2 { transform: rotate(2deg); }
.chaos-card--tilt-3 { transform: rotate(-1.5deg); }
.chaos-card--tilt-4 { transform: rotate(4deg); }
.chaos-card--tilt-5 { transform: rotate(-4.5deg); }
.chaos-card-title {
margin: var(--spacing-xs) 0;
color: var(--color-text-primary);
}
.chaos-card-author,
.chaos-card-notes,
.chaos-card-recipe {
margin: 0;
}
@media (max-width: 480px) {
.chaos-grid {
grid-template-columns: 1fr;
}
.chaos-card--tilt-1,
.chaos-card--tilt-2,
.chaos-card--tilt-3,
.chaos-card--tilt-4,
.chaos-card--tilt-5 {
transform: none;
}
}
</style>