From 667daf939ec89372af32f275023336c667675d55 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 12:46:27 -0700 Subject: [PATCH] feat(streaming): replace raw
 with skeleton +
 progressive reveal (closes #133)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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 
 block
is removed from the template entirely — raw tokens never reach the user.
---
 frontend/src/components/RecipesView.vue | 152 ++++++++++++++++++++++--
 1 file changed, 141 insertions(+), 11 deletions(-)

diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue
index b946918..b37717b 100644
--- a/frontend/src/components/RecipesView.vue
+++ b/frontend/src/components/RecipesView.vue
@@ -460,12 +460,50 @@
 
     
     
-
+
Generating recipe…
-
{{ streamError }}
-
{{ streamChunks }}
+ + + +
+ +
+

{{ parsedStream.title }}

+
+
+ + +
+ +
    +
  • {{ ing }}
  • +
+ +
+ + +
+ +
    +
  1. {{ step }}
  2. +
+ +
+ + +

+ Notes: {{ parsedStream.notes }} +

+
@@ -760,6 +798,41 @@ const isStreaming = ref(false) const streamChunks = ref('') const streamError = ref(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 inventoryStore = useInventoryStore() @@ -2041,13 +2114,70 @@ details[open] .collapsible-summary::before { margin-bottom: 0.5rem; } -.stream-output { - font-family: inherit; - white-space: pre-wrap; - font-size: var(--font-size-sm); - color: var(--color-text); - margin: 0; - max-height: 400px; - overflow-y: auto; +/* ── Stream recipe card — skeleton + progressive reveal ─────────────────── */ +.stream-recipe-card { + margin-top: var(--spacing-sm); +} + +.stream-section-label { + display: block; + 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); + } }