feat(streaming): replace raw <pre> with skeleton + progressive reveal (closes #133)

Parses the streamed LLM output (Title / Ingredients / Directions / Notes
plain-text format) on the fly as tokens arrive. Shows a shimmer skeleton
for each section while that section has not yet arrived, then swaps in
real content as the parse succeeds — title first, then ingredients, then
numbered steps, then notes on completion.

parsedStream computed: matches Title, Ingredients (comma-split), numbered
step lines, and Notes sections from the accumulating streamChunks string.

Skeleton shimmer is CSS-only (no JS); respects prefers-reduced-motion by
falling back to a static placeholder color. The stream-output <pre> block
is removed from the template entirely — raw tokens never reach the user.
This commit is contained in:
pyr0ball 2026-05-11 12:46:27 -07:00
parent 4e50661483
commit 667daf939e

View file

@ -460,12 +460,50 @@
<!-- Streaming recipe generation panel --> <!-- Streaming recipe generation panel -->
<div v-if="isStreaming || streamChunks || streamError" class="stream-panel"> <div v-if="isStreaming || streamChunks || streamError" class="stream-panel">
<div v-if="isStreaming" class="stream-status"> <div v-if="isStreaming" class="stream-status" role="status">
<span class="stream-dot" aria-hidden="true"></span> <span class="stream-dot" aria-hidden="true"></span>
Generating recipe Generating recipe
</div> </div>
<div v-if="streamError" class="stream-error text-sm">{{ streamError }}</div> <div v-if="streamError" class="stream-error text-sm" role="alert">{{ streamError }}</div>
<pre v-if="streamChunks" class="stream-output">{{ streamChunks }}</pre>
<!-- Progressive reveal card: skeleton while streaming, full card on complete -->
<div v-if="isStreaming || (streamChunks && !streamError)" class="stream-recipe-card card">
<!-- Title -->
<div class="stream-title mb-sm">
<h3 v-if="parsedStream.title" class="text-lg font-semibold">{{ parsedStream.title }}</h3>
<div v-else class="skeleton-line skeleton-title"></div>
</div>
<!-- Ingredients -->
<div class="stream-section mb-sm">
<strong class="stream-section-label text-sm">Ingredients</strong>
<ul v-if="parsedStream.ingredients.length > 0" class="stream-ingredients mt-xs">
<li v-for="(ing, i) in parsedStream.ingredients" :key="i" class="text-sm">{{ ing }}</li>
</ul>
<template v-else-if="isStreaming">
<div class="skeleton-line skeleton-medium mt-xs"></div>
<div class="skeleton-line skeleton-short"></div>
<div class="skeleton-line skeleton-long"></div>
</template>
</div>
<!-- Steps only show once ingredients have arrived -->
<div v-if="parsedStream.steps.length > 0 || (isStreaming && parsedStream.ingredients.length > 0)" class="stream-section mb-sm">
<strong class="stream-section-label text-sm">Directions</strong>
<ol v-if="parsedStream.steps.length > 0" class="stream-steps mt-xs">
<li v-for="(step, i) in parsedStream.steps" :key="i" class="text-sm">{{ step }}</li>
</ol>
<template v-else-if="isStreaming">
<div class="skeleton-line skeleton-long mt-xs"></div>
<div class="skeleton-line skeleton-medium"></div>
</template>
</div>
<!-- Notes only after stream complete -->
<p v-if="parsedStream.notes && !isStreaming" class="stream-notes text-sm text-muted">
<strong>Notes:</strong> {{ parsedStream.notes }}
</p>
</div>
</div> </div>
<!-- Screen reader announcement for loading + results --> <!-- Screen reader announcement for loading + results -->
@ -760,6 +798,41 @@ const isStreaming = ref(false)
const streamChunks = ref('') const streamChunks = ref('')
const streamError = ref<string | null>(null) const streamError = ref<string | null>(null)
interface ParsedStreamRecipe {
title: string | null
ingredients: string[]
steps: string[]
notes: string | null
}
const parsedStream = computed((): ParsedStreamRecipe => {
const text = streamChunks.value
if (!text) return { title: null, ingredients: [], steps: [], notes: null }
const titleMatch = text.match(/^Title:\s*(.+)$/m)
const title = titleMatch ? titleMatch[1].trim() : null
const ingMatch = text.match(/^Ingredients:\s*(.+)$/m)
const ingredients = ingMatch
? ingMatch[1].split(',').map((s) => s.trim()).filter(Boolean)
: []
const steps: string[] = []
const dirIdx = text.indexOf('Directions:')
const notesIdx = text.indexOf('\nNotes:')
if (dirIdx !== -1) {
const dirSection = text.slice(dirIdx + 'Directions:'.length, notesIdx !== -1 ? notesIdx : undefined)
for (const m of dirSection.matchAll(/^\d+\.\s*(.+)$/mg)) {
steps.push(m[1].trim())
}
}
const notesMatch = text.match(/^Notes:\s*([\s\S]+)$/m)
const notes = notesMatch ? notesMatch[1].trim() : null
return { title, ingredients, steps, notes }
})
const recipesStore = useRecipesStore() const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore() const inventoryStore = useInventoryStore()
@ -2041,13 +2114,70 @@ details[open] .collapsible-summary::before {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.stream-output { /* ── Stream recipe card — skeleton + progressive reveal ─────────────────── */
font-family: inherit; .stream-recipe-card {
white-space: pre-wrap; margin-top: var(--spacing-sm);
font-size: var(--font-size-sm); }
color: var(--color-text);
margin: 0; .stream-section-label {
max-height: 400px; display: block;
overflow-y: auto; color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: var(--font-size-xs, 0.72rem);
margin-bottom: var(--spacing-xs);
}
.stream-ingredients {
padding-left: 1.2rem;
list-style: disc;
display: flex;
flex-direction: column;
gap: 2px;
}
.stream-steps {
padding-left: 1.4rem;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.stream-notes {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-xs);
margin-top: var(--spacing-xs);
}
/* ── Skeleton shimmer ───────────────────────────────────────────────────── */
@keyframes skeleton-shimmer {
0% { background-position: -200px 0; }
100% { background-position: calc(200px + 100%) 0; }
}
.skeleton-line {
height: 0.8rem;
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-secondary, #f5f5f5) 0%,
var(--color-border, #e0e0e0) 50%,
var(--color-bg-secondary, #f5f5f5) 100%
);
background-size: 200px 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
margin-bottom: var(--spacing-xs);
}
.skeleton-title { height: 1.2rem; width: 55%; }
.skeleton-short { width: 38%; }
.skeleton-medium { width: 65%; }
.skeleton-long { width: 88%; }
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
background: var(--color-bg-secondary, #f5f5f5);
}
} }
</style> </style>