ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Image generation with gpt-image-1.5 high, webp and 1024x1024 (#412)

* what does this look like

* bunch of other changes need second review

* usage waitgroup and in memory fixups

* try and change recipe page less

* layout seems better

* comment and fumpt

* okay dalle-3 seems like a winner. Lets see if it costs too much

* okay I think we have something

* okay newer model high quality working pretty well

* fumpt

* get rid of contenty type

* more fumpt

* fprintf and cache control

* seperate cache for images

* small simplifications to templates but still looking good

* revert wine for now

* fumpt and tailwind

* close wine reader

* better mock

* fumpt

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
ab3ca0a0 d5cf4737

+650 -77
+7 -3
cmd/careme/web.go
··· 15 15 "careme/internal/actowiz" 16 16 "careme/internal/admin" 17 17 "careme/internal/auth" 18 - "careme/internal/cache" 18 + cachepkg "careme/internal/cache" 19 19 "careme/internal/config" 20 20 "careme/internal/ingredients" 21 21 "careme/internal/locations" ··· 31 31 ) 32 32 33 33 func runServer(cfg *config.Config, addr string) error { 34 - cache, err := cache.MakeCache() 34 + cache, err := cachepkg.MakeCache() 35 35 if err != nil { 36 36 return fmt.Errorf("failed to create cache: %w", err) 37 + } 38 + imageCache, err := cachepkg.EnsureCache(recipes.RecipeImagesContainer) 39 + if err != nil { 40 + return fmt.Errorf("failed to create recipe image cache: %w", err) 37 41 } 38 42 39 43 authClient, err := auth.NewFromConfig(cfg) ··· 73 77 sitemapHandler := sitemap.New(cache, cfg.ResolvedPublicOrigin()) 74 78 sitemapHandler.Register(infraRoutes) 75 79 76 - recipeHandler := recipes.NewHandler(cfg, userStorage, generator, locationStorage, cache, authClient) 80 + recipeHandler := recipes.NewHandler(cfg, userStorage, generator, locationStorage, cache, imageCache, authClient) 77 81 recipeHandler.Register(appRoutes) 78 82 79 83 actowiz.NewServer(locationStorage).Register(infraRoutes)
+1 -1
cmd/careme/web_e2e_test.go
··· 183 183 locationServer := locations.NewServer(locationStorage, centroids, userStorage) 184 184 locationServer.Register(appRoutes, mockAuth) 185 185 users.NewHandler(userStorage, locationStorage, mockAuth).Register(appRoutes) 186 - recipes.NewHandler(cfg, userStorage, generator, locationStorage, cacheStore, mockAuth).Register(appRoutes) 186 + recipes.NewHandler(cfg, userStorage, generator, locationStorage, cacheStore, cacheStore, mockAuth).Register(appRoutes) 187 187 188 188 ro := &readyOnce{} 189 189 ro.Add(generator, locationServer)
+6 -1
docs/cache-layout.md
··· 8 8 - Local filesystem under `heb/` (HEB cache) 9 9 - Local filesystem under `publix/` (Publix cache) 10 10 - Local filesystem under `wholefoods/` (Whole Foods cache) 11 + - Local filesystem under `recipe-images/` (recipe image cache) 11 12 - Azure Blob container `recipes` (default app cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 13 + - Azure Blob container `recipe-images` (recipe image cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 12 14 - Azure Blob container `aldi` (ALDI cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 13 15 - Azure Blob container `albertsons` (Albertsons-family cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 14 16 - Azure Blob container `publix` (Publix cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) ··· 26 28 | `ingredients/` | JSON `[]kroger.Ingredient` keyed by location hash (staples) or by wine style/date/location hash (wine candidate cache) | `internal/recipes/io.go` (`SaveIngredients`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | `internal/recipes/io.go` (`IngredientsFromCache`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | 27 29 | `params/` | JSON `generatorParams` keyed by shopping hash; params no longer embed the resolved staple filter list | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 28 30 | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveRecipes`) | `internal/recipes/io.go` (`SingleFromCache`) | 31 + | `recipe_images/` | WebP bytes for single-recipe dish images keyed by recipe hash in the dedicated `recipe-images` cache backend | `internal/recipes/image.go` (`SaveRecipeImage`) via `internal/recipes/server.go` (`POST /recipe/{hash}/image`) | `internal/recipes/image.go` (`RecipeImageFromCache`, `RecipeImageExists`) via `internal/recipes/server.go` (`GET /recipe/{hash}/image`, `handleSingle`) | 29 32 | `wine_recommendations/` | Plain text wine recommendation keyed by recipe hash | `internal/recipes/wine.go` (`SaveWine`) via `internal/recipes/server.go` (`handleWine`) | `internal/recipes/wine.go` (`WineFromCache`) via `internal/recipes/server.go` (`handleWine`) | 30 33 | `recipe_selection/` | JSON `recipeSelection` (`saved_hashes`, `dismissed_hashes`, `updated_at`) keyed by `<user_id>/<origin_hash>` | `internal/recipes/selection.go` (`saveRecipeSelection`) via `internal/recipes/server.go` (`handleSaveRecipe`, `handleDismissRecipe`) | `internal/recipes/selection.go` (`loadRecipeSelection`) via `internal/recipes/server.go` (`handleRegenerate`, `handleFinalize`, `handleRecipes`) | 31 34 | `recipe_thread/` | JSON `[]RecipeThreadEntry` (Q/A thread for a recipe hash) | `internal/recipes/thread.go` (`SaveThread`) | `internal/recipes/thread.go` (`ThreadFromCache`) | ··· 52 55 - Albertsons-family locations use a separate cache created via `cache.EnsureCache("albertsons")`. 53 56 - HEB locations use a separate cache created via `cache.EnsureCache("heb")`. 54 57 - Publix uses a separate cache created via `cache.EnsureCache("publix")`; it does not share the `recipes` container/directory. 58 + - Recipe images use a separate cache created via `cache.EnsureCache("recipe-images")`; they do not share the main `recipes` container/directory. 55 59 - Whole Foods uses a separate cache created via `cache.EnsureCache("wholefoods")`; it does not share the `recipes` container/directory. 56 - - Local cache paths are `recipes/` for most app data, `aldi/` for ALDI data, `albertsons/` for Albertsons-family data, `heb/` for HEB data, `publix/` for Publix data, and `wholefoods/` for Whole Foods data when filesystem backend is used. 60 + - Local cache paths are `recipes/` for most app data, `recipe-images/` for recipe images, `aldi/` for ALDI data, `albertsons/` for Albertsons-family data, `heb/` for HEB data, `publix/` for Publix data, and `wholefoods/` for Whole Foods data when filesystem backend is used. 57 61 - Blob names in Azure match the same key strings listed above inside their respective containers. 58 62 - Staple `ingredients/` cache keys derive from location ID, date, and a versioned backend staple signature (for example `kroger-staples-v1` or `wholefoods-staples-v1`), so Kroger and Whole Foods locations do not share staple caches and staple-definition changes can invalidate caches intentionally. 63 + - Recipe image cache keys are stable per recipe hash, so prompt or model changes do not orphan previously generated images. 59 64 - Do not create nested keys under `recipe/<hash>` (for example `recipe/<hash>/wine`) because `FileCache` stores `recipe/<hash>` as a file path.
+68 -5
internal/ai/client.go
··· 30 30 model string 31 31 } 32 32 33 + type GeneratedImage struct { 34 + Body io.Reader 35 + } 36 + 33 37 // todo collapse closer to 34 38 type Ingredient struct { 35 39 Name string `json:"name"` ··· 149 153 - Verify technical terms are used correctly. 150 154 - Verify the dish have a good appearance after plating` 151 155 152 - func PromptSignature() []byte { 153 - fnv := fnv.New32a() 154 - lo.Must(io.WriteString(fnv, systemMessage)) 155 - return fnv.Sum(nil) 156 - } 156 + const recipeImagePromptInstructions = ` 157 + Generate a realistic overhead food photograph of a single finished plate. 158 + - Home cooked by a above average cook, not a restaurant or food stylist. 159 + - Keep plating simple and believable. No tweezers, foam, edible flowers, microgreens, or luxury flourishes unless in recipe instructions. 160 + - Use a simple kitchen counter, stovetop, sheet pan, wooden table, or casual dining table backdrop. 161 + - Use natural colors, ordinary cookware or tableware, and realistic portions 162 + - Avoid text, labels, branded packaging, people, hands, collages, and extra side dishes 163 + - If the recipe has multiple components, show them plated together 164 + ` 165 + 166 + const ( 167 + recipeImageModel = openai.ImageModelGPTImage1_5 // dalle-3 is getting deprecated. 1.5 seems way better than 1. 168 + // WebP is materially smaller for these recipe photos on mobile, and GPT image models support direct WebP output. 169 + recipeImageOutputFormat = openai.ImageGenerateParamsOutputFormatWebP 170 + recipeImageQuality = openai.ImageGenerateParamsQualityHigh 171 + recipeImageSize = openai.ImageGenerateParamsSize1024x1024 172 + ) 157 173 158 174 func responseToShoppingList(ctx context.Context, resp *responses.Response) (*ShoppingList, error) { 159 175 slog.InfoContext(ctx, "API usage", slog.Any("usage", json.RawMessage(resp.Usage.RawJSON()))) ··· 240 256 return answer, nil 241 257 } 242 258 259 + func (c *Client) GenerateRecipeImage(ctx context.Context, recipe Recipe) (*GeneratedImage, error) { 260 + prompt, err := buildRecipeImagePrompt(recipe) 261 + if err != nil { 262 + return nil, fmt.Errorf("failed to build recipe image prompt: %w", err) 263 + } 264 + 265 + client := openai.NewClient(option.WithAPIKey(c.apiKey)) 266 + resp, err := client.Images.Generate(ctx, openai.ImageGenerateParams{ 267 + Prompt: prompt, 268 + Model: recipeImageModel, 269 + N: openai.Int(1), 270 + OutputFormat: recipeImageOutputFormat, 271 + Quality: recipeImageQuality, 272 + Size: recipeImageSize, 273 + }) 274 + if err != nil { 275 + return nil, fmt.Errorf("failed to generate recipe image: %w", err) 276 + } 277 + 278 + slog.InfoContext(ctx, "API usage", slog.Any("usage", json.RawMessage(resp.Usage.RawJSON()))) 279 + if len(resp.Data) == 0 { 280 + return nil, fmt.Errorf("image generation returned no images") 281 + } 282 + imageBody := strings.TrimSpace(resp.Data[0].B64JSON) 283 + if imageBody == "" { 284 + return nil, fmt.Errorf("image generation returned empty image data") 285 + } 286 + 287 + return &GeneratedImage{ 288 + Body: base64.NewDecoder(base64.StdEncoding, strings.NewReader(imageBody)), 289 + }, nil 290 + } 291 + 243 292 func (c *Client) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*WineSelection, error) { 244 293 conversationID = strings.TrimSpace(conversationID) 245 294 recipeTitle = strings.TrimSpace(recipeTitle) ··· 328 377 329 378 func user(msg string) responses.ResponseInputItemUnionParam { 330 379 return responses.ResponseInputItemParamOfMessage(msg, responses.EasyInputMessageRoleUser) 380 + } 381 + 382 + func buildRecipeImagePrompt(recipe Recipe) (string, error) { 383 + var promptBuilder strings.Builder 384 + fmt.Fprintf(&promptBuilder, "%s\n", recipeImagePromptInstructions) 385 + fmt.Fprintf(&promptBuilder, "\n") 386 + fmt.Fprintf(&promptBuilder, "Recipe:\n") 387 + fmt.Fprintf(&promptBuilder, "%s\n", recipe.Title) 388 + fmt.Fprintf(&promptBuilder, "%s\n", recipe.Description) 389 + fmt.Fprintf(&promptBuilder, "Instructions:\n") 390 + for _, ins := range recipe.Instructions { 391 + fmt.Fprintf(&promptBuilder, "%s\n", ins) 392 + } 393 + return promptBuilder.String(), nil 331 394 } 332 395 333 396 // buildRecipeMessages creates separate messages for the LLM to process more efficiently
+21
internal/ai/recipe_test.go
··· 2 2 3 3 import ( 4 4 "slices" 5 + "strings" 5 6 "testing" 6 7 ) 7 8 ··· 92 93 t.Fatalf("unexpected normalized wine styles: got %#v want %#v", got, want) 93 94 } 94 95 } 96 + 97 + func TestBuildRecipeImagePrompt(t *testing.T) { 98 + recipe := Recipe{ 99 + Title: "Roast Chicken", 100 + Description: "Crisp skin and herbs.", 101 + Ingredients: []Ingredient{{Name: "Chicken", Quantity: "1 whole"}}, 102 + Instructions: []string{"Roast until golden."}, 103 + } 104 + 105 + prompt, err := buildRecipeImagePrompt(recipe) 106 + if err != nil { 107 + t.Fatalf("buildRecipeImagePrompt returned error: %v", err) 108 + } 109 + if !strings.Contains(prompt, "realistic overhead food photograph") { 110 + t.Fatalf("expected image prompt instructions in prompt: %s", prompt) 111 + } 112 + if !strings.Contains(prompt, "Recipe:\nRoast Chicken\nCrisp skin and herbs.\nInstructions:\nRoast until golden.\n") { 113 + t.Fatalf("expected recipe summary in prompt: %s", prompt) 114 + } 115 + }
+5 -1
internal/cache/azure.go
··· 98 98 } 99 99 100 100 func (fc *BlobCache) Put(ctx context.Context, key, value string, opts PutOptions) error { 101 + return fc.PutReader(ctx, key, strings.NewReader(value), opts) 102 + } 103 + 104 + func (fc *BlobCache) PutReader(ctx context.Context, key string, reader io.Reader, opts PutOptions) error { 101 105 var access *blob.AccessConditions 102 106 if opts.Condition == PutIfNoneMatch { 103 107 access = &blob.AccessConditions{} ··· 107 111 // TODO: IfMatch support. 108 112 } 109 113 110 - _, err := fc.container.NewBlockBlobClient(key).UploadStream(ctx, strings.NewReader(value), &azblob.UploadStreamOptions{ 114 + _, err := fc.container.NewBlockBlobClient(key).UploadStream(ctx, reader, &azblob.UploadStreamOptions{ 111 115 AccessConditions: access, 112 116 }) 113 117 if err != nil {
+12 -7
internal/cache/file.go
··· 41 41 Get(ctx context.Context, key string) (io.ReadCloser, error) 42 42 Exists(ctx context.Context, key string) (bool, error) 43 43 Put(ctx context.Context, key, value string, opts PutOptions) error 44 + PutReader(ctx context.Context, key string, reader io.Reader, opts PutOptions) error 44 45 } 45 46 46 47 type ListCache interface { ··· 112 113 return data, nil 113 114 } 114 115 115 - func (fc *FileCache) Put(_ context.Context, key, value string, opts PutOptions) error { 116 + func (fc *FileCache) Put(ctx context.Context, key, value string, opts PutOptions) error { 117 + return fc.PutReader(ctx, key, strings.NewReader(value), opts) 118 + } 119 + 120 + func (fc *FileCache) PutReader(_ context.Context, key string, reader io.Reader, opts PutOptions) error { 116 121 fullPath := filepath.Join(fc.Dir, key) 117 122 dir := filepath.Dir(fullPath) 118 123 if err := os.MkdirAll(dir, 0o755); err != nil { ··· 120 125 } 121 126 122 127 if opts.Condition == PutIfNoneMatch { 123 - return writeIfNoneMatchAtomic(dir, fullPath, value) 128 + return writeIfNoneMatchAtomic(dir, fullPath, reader) 124 129 } 125 130 126 131 // TODO: IfMatch support (write only if etag matches). 127 - return writeAtomic(dir, fullPath, value) 132 + return writeAtomic(dir, fullPath, reader) 128 133 } 129 134 130 - func writeAtomic(dir, targetPath, value string) error { 135 + func writeAtomic(dir, targetPath string, reader io.Reader) error { 131 136 tmpFile, err := os.CreateTemp(dir, ".tmp-*") 132 137 if err != nil { 133 138 return err ··· 137 142 _ = os.Remove(tmpPath) 138 143 }() 139 144 140 - if _, err := tmpFile.WriteString(value); err != nil { 145 + if _, err := io.Copy(tmpFile, reader); err != nil { 141 146 if closeErr := tmpFile.Close(); closeErr != nil { 142 147 return errors.Join(err, closeErr) 143 148 } ··· 150 155 return os.Rename(tmpPath, targetPath) 151 156 } 152 157 153 - func writeIfNoneMatchAtomic(dir, targetPath, value string) error { 158 + func writeIfNoneMatchAtomic(dir, targetPath string, reader io.Reader) error { 154 159 tmpFile, err := os.CreateTemp(dir, ".tmp-*") 155 160 if err != nil { 156 161 return err ··· 160 165 _ = os.Remove(tmpPath) 161 166 }() 162 167 163 - if _, err := tmpFile.WriteString(value); err != nil { 168 + if _, err := io.Copy(tmpFile, reader); err != nil { 164 169 if closeErr := tmpFile.Close(); closeErr != nil { 165 170 return errors.Join(err, closeErr) 166 171 }
+15 -5
internal/cache/memory.go
··· 1 1 package cache 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "io" 6 7 "sort" ··· 11 12 // InMemoryCache stores cache entries in process memory. 12 13 type InMemoryCache struct { 13 14 mu sync.RWMutex 14 - data map[string]string 15 + data map[string][]byte 15 16 } 16 17 17 18 var ( ··· 21 22 22 23 func NewInMemoryCache() *InMemoryCache { 23 24 return &InMemoryCache{ 24 - data: make(map[string]string), 25 + data: make(map[string][]byte), 25 26 } 26 27 } 27 28 ··· 32 33 if !ok { 33 34 return nil, ErrNotFound 34 35 } 35 - return io.NopCloser(strings.NewReader(value)), nil 36 + return io.NopCloser(bytes.NewReader(value)), nil 36 37 } 37 38 38 39 func (c *InMemoryCache) Exists(_ context.Context, key string) (bool, error) { ··· 42 43 return ok, nil 43 44 } 44 45 45 - func (c *InMemoryCache) Put(_ context.Context, key, value string, opts PutOptions) error { 46 + func (c *InMemoryCache) Put(ctx context.Context, key, value string, opts PutOptions) error { 47 + return c.PutReader(ctx, key, strings.NewReader(value), opts) 48 + } 49 + 50 + func (c *InMemoryCache) PutReader(_ context.Context, key string, reader io.Reader, opts PutOptions) error { 51 + var buf bytes.Buffer 52 + if _, err := io.Copy(&buf, reader); err != nil { 53 + return err 54 + } 55 + 46 56 c.mu.Lock() 47 57 defer c.mu.Unlock() 48 58 ··· 52 62 } 53 63 } 54 64 55 - c.data[key] = value 65 + c.data[key] = buf.Bytes() 56 66 return nil 57 67 } 58 68
+4
internal/locations/test_helpers_test.go
··· 111 111 return f.putErr 112 112 } 113 113 114 + func (f failingListCache) PutReader(_ context.Context, _ string, _ io.Reader, _ cachepkg.PutOptions) error { 115 + return f.putErr 116 + } 117 + 114 118 func (f failingListCache) List(context.Context, string, string) ([]string, error) { 115 119 return nil, nil 116 120 }
+8
internal/mail/mail_test.go
··· 75 75 return nil 76 76 } 77 77 78 + func (c *fakeMailCache) PutReader(_ context.Context, key string, reader io.Reader, opts cache.PutOptions) error { 79 + body, err := io.ReadAll(reader) 80 + if err != nil { 81 + return err 82 + } 83 + return c.Put(context.Background(), key, string(body), opts) 84 + } 85 + 78 86 type fakeMailLocServer struct { 79 87 location *locations.Location 80 88 }
+5
internal/recipes/generator.go
··· 28 28 GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) 29 29 Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) 30 30 AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 31 + GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) 31 32 PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) 32 33 Ready(ctx context.Context) error 33 34 } ··· 171 172 172 173 func (g *Generator) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 173 174 return g.aiClient.AskQuestion(ctx, question, conversationID) 175 + } 176 + 177 + func (g *Generator) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 178 + return g.aiClient.GenerateRecipeImage(ctx, recipe) 174 179 } 175 180 176 181 // calls get ingredients for a number of "staples" basically fresh produce and vegatbles.
+8
internal/recipes/generator_test.go
··· 45 45 return c.answer, nil 46 46 } 47 47 48 + func (c *captureWineQuestionAIClient) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 49 + panic("unexpected call to GenerateRecipeImage") 50 + } 51 + 48 52 func (c *captureWineQuestionAIClient) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) { 49 53 c.recipeTitle = recipeTitle 50 54 if c.selection != nil { ··· 75 79 76 80 func (c *captureRegenerateAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 77 81 panic("unexpected call to AskQuestion") 82 + } 83 + 84 + func (c *captureRegenerateAIClient) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 85 + panic("unexpected call to GenerateRecipeImage") 78 86 } 79 87 80 88 func (c *captureRegenerateAIClient) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) {
+53 -2
internal/recipes/html.go
··· 15 15 "careme/internal/templates" 16 16 ) 17 17 18 + type recipeImageView struct { 19 + HasImage bool 20 + Hash string 21 + // OutOfBand lets the shared panel template opt into the HTMX outerHTML swap 22 + // used by the image-generation response without duplicating the panel markup. 23 + OutOfBand bool 24 + } 25 + 18 26 // shoppingRecipeView is a thin wrapper around ai.Recipe for the shopping list page. 19 27 // 20 28 // We keep ingredient expansion in Go instead of the template because the same derived ··· 121 129 } 122 130 123 131 // FormatRecipeHTML renders a single recipe view with a browser session id for analytics. 124 - func FormatRecipeHTML(ctx context.Context, p *generatorParams, recipe ai.Recipe, signedIn bool, thread []RecipeThreadEntry, fb feedback.Feedback, wineRecommendation *ai.WineSelection, writer http.ResponseWriter) { 132 + func FormatRecipeHTML(ctx context.Context, p *generatorParams, recipe ai.Recipe, signedIn bool, hasRecipeImage bool, thread []RecipeThreadEntry, fb feedback.Feedback, wineRecommendation *ai.WineSelection, writer http.ResponseWriter) { 125 133 slices.SortFunc(thread, func(i, j RecipeThreadEntry) int { 126 134 return j.CreatedAt.Compare(i.CreatedAt) 127 135 }) 136 + recipeHash := recipe.ComputeHash() 128 137 data := struct { 129 138 Location locations.Location 130 139 Date string ··· 138 147 Thread []RecipeThreadEntry 139 148 Feedback feedback.Feedback 140 149 RecipeHash string 150 + RecipeImage recipeImageView 141 151 Style seasons.Style 142 152 ServerSignedIn bool 143 153 }{ ··· 152 162 WineRecommendation: wineRecommendation, 153 163 Thread: thread, 154 164 Feedback: fb, 155 - RecipeHash: recipe.ComputeHash(), 165 + RecipeHash: recipeHash, 166 + RecipeImage: recipeImageData(recipeHash, hasRecipeImage, false), 156 167 Style: seasons.GetCurrentStyle(), 157 168 ServerSignedIn: signedIn, 158 169 } 159 170 160 171 if err := templates.Recipe.Execute(writer, data); err != nil { 161 172 http.Error(writer, "recipe template error: "+err.Error(), http.StatusInternalServerError) 173 + } 174 + } 175 + 176 + func recipeImageData(recipeHash string, hasImage bool, outOfBand bool) recipeImageView { 177 + return recipeImageView{ 178 + HasImage: hasImage, 179 + Hash: recipeHash, 180 + OutOfBand: outOfBand, 181 + } 182 + } 183 + 184 + func FormatRecipeImageActionHTML(recipeHash string, signedIn bool, hasRecipeImage bool, writer http.ResponseWriter) { 185 + data := struct { 186 + RecipeHash string 187 + RecipeImage recipeImageView 188 + ServerSignedIn bool 189 + }{ 190 + RecipeHash: recipeHash, 191 + RecipeImage: recipeImageData(recipeHash, hasRecipeImage, false), 192 + ServerSignedIn: signedIn, 193 + } 194 + 195 + if err := templates.Recipe.ExecuteTemplate(writer, "recipe_image_action", data); err != nil { 196 + http.Error(writer, "recipe image action template error: "+err.Error(), http.StatusInternalServerError) 197 + } 198 + } 199 + 200 + func FormatRecipeImageActionResponseHTML(recipeHash string, signedIn bool, hasRecipeImage bool, writer http.ResponseWriter) { 201 + data := struct { 202 + RecipeHash string 203 + RecipeImage recipeImageView 204 + ServerSignedIn bool 205 + }{ 206 + RecipeHash: recipeHash, 207 + RecipeImage: recipeImageData(recipeHash, hasRecipeImage, true), 208 + ServerSignedIn: signedIn, 209 + } 210 + 211 + if err := templates.Recipe.ExecuteTemplate(writer, "recipe_image_action_response", data); err != nil { 212 + http.Error(writer, "recipe image response template error: "+err.Error(), http.StatusInternalServerError) 162 213 } 163 214 } 164 215
+37 -10
internal/recipes/html_test.go
··· 202 202 p := DefaultParams(&loc, time.Now()) 203 203 p.ConversationID = "convo123" 204 204 w := httptest.NewRecorder() 205 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 205 + FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 206 206 html := w.Body.String() 207 207 208 208 isValidHTML(t, html) ··· 235 235 t.Error("recipe HTML should include wine picker htmx endpoint") 236 236 } 237 237 if !strings.Contains(html, "Choose a wine") { 238 - t.Error("recipe HTML should include choose a wine button") 238 + t.Error("recipe HTML should include choose wine button") 239 + } 240 + if !strings.Contains(html, "See plated dish") { 241 + t.Error("recipe HTML should include see plated dish button") 242 + } 243 + if !strings.Contains(html, "htmx-indicator") { 244 + t.Error("recipe HTML should include button spinners for async actions") 239 245 } 240 246 if !strings.Contains(html, "Cook time:") { 241 247 t.Error("recipe HTML should contain cook time") ··· 265 271 p := DefaultParams(&loc, time.Now()) 266 272 p.ConversationID = "convo123" 267 273 w := httptest.NewRecorder() 268 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 274 + FormatRecipeHTML(t.Context(), p, list.Recipes[0], false, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 269 275 html := w.Body.String() 270 276 271 277 isValidHTML(t, html) ··· 276 282 if !strings.Contains(html, "Sign in to ask follow-up questions") { 277 283 t.Error("recipe HTML should prompt signed-out users to sign in for questions") 278 284 } 279 - if strings.Contains(html, `hx-post="/recipe/`) && strings.Contains(html, `/wine"`) { 285 + if strings.Contains(html, `/recipe/`) && strings.Contains(html, `/wine"`) { 280 286 t.Error("recipe HTML should not expose wine picker htmx endpoint when signed out") 281 - } 282 - if !strings.Contains(html, "Sign in for wine picks") { 283 - t.Error("recipe HTML should prompt signed-out users to sign in for wine picks") 284 287 } 285 288 if strings.Contains(html, `name="feedback"`) { 286 289 t.Error("recipe HTML should not contain feedback form when signed out") ··· 344 347 p := DefaultParams(&loc, time.Now()) 345 348 p.ConversationID = "convo123" 346 349 w := httptest.NewRecorder() 347 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, []RecipeThreadEntry{}, feedback.Feedback{}, &ai.WineSelection{ 350 + FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, false, []RecipeThreadEntry{}, feedback.Feedback{}, &ai.WineSelection{ 348 351 Wines: []ai.Ingredient{ 349 352 {Name: "Oregon Pinot Noir", Price: "$14.99"}, 350 353 {Name: "Backup Chardonnay", Price: "$11.99"}, ··· 367 370 if got := strings.Count(html, "Backup Chardonnay"); got != 1 { 368 371 t.Errorf("recipe HTML should only show backup wine in recommendation, got count %d", got) 369 372 } 370 - if strings.Contains(html, "choose a wine") { 371 - t.Error("recipe HTML should not render choose a wine button when recommendation exists") 373 + if strings.Contains(html, "Choose a wine") { 374 + t.Error("recipe HTML should not render the wine picker when recommendation exists") 375 + } 376 + } 377 + 378 + func TestFormatRecipeHTML_RendersRecipeImage(t *testing.T) { 379 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 380 + p := DefaultParams(&loc, time.Now()) 381 + p.ConversationID = "convo123" 382 + w := httptest.NewRecorder() 383 + recipe := list.Recipes[0] 384 + recipeHash := recipe.ComputeHash() 385 + 386 + FormatRecipeHTML(t.Context(), p, recipe, true, true, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 387 + html := w.Body.String() 388 + 389 + isValidHTML(t, html) 390 + 391 + if !strings.Contains(html, `id="recipe-image-panel"`) { 392 + t.Fatal("recipe HTML should render the recipe image panel") 393 + } 394 + if !strings.Contains(html, "/recipe/"+recipeHash+"/image") { 395 + t.Fatalf("recipe HTML should render the cached recipe image URL, got body: %s", html) 396 + } 397 + if strings.Contains(html, "View dish image") || strings.Contains(html, "See plated dish") { 398 + t.Fatalf("recipe HTML should not render an image action when an image exists, got body: %s", html) 372 399 } 373 400 } 374 401
+42
internal/recipes/image.go
··· 1 + package recipes 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + 8 + "careme/internal/ai" 9 + "careme/internal/cache" 10 + ) 11 + 12 + const ( 13 + RecipeImagesContainer = "images" 14 + recipeImagesCachePrefix = "recipes/" 15 + ) 16 + 17 + func recipeImageCacheKey(hash string) string { 18 + return recipeImagesCachePrefix + hash 19 + } 20 + 21 + type imageio struct { 22 + Cache cache.Cache 23 + } 24 + 25 + func (iio imageio) RecipeImageExists(ctx context.Context, hash string) (bool, error) { 26 + return iio.Cache.Exists(ctx, recipeImageCacheKey(hash)) 27 + } 28 + 29 + func (iio imageio) RecipeImageFromCache(ctx context.Context, hash string) (io.ReadCloser, error) { 30 + return iio.Cache.Get(ctx, recipeImageCacheKey(hash)) 31 + } 32 + 33 + func (iio imageio) SaveRecipeImage(ctx context.Context, hash string, image *ai.GeneratedImage) error { 34 + if image == nil { 35 + return fmt.Errorf("recipe image is required") 36 + } 37 + if image.Body == nil { 38 + return fmt.Errorf("recipe image body is required") 39 + } 40 + // TODO store content meta data somewher? 41 + return iio.Cache.PutReader(ctx, recipeImageCacheKey(hash), image.Body, cache.Unconditional()) 42 + }
+2 -2
internal/recipes/io.go
··· 23 23 ) 24 24 25 25 type recipeio struct { 26 - Cache cache.Cache 27 - feedback.FeedbackIO 26 + Cache cache.Cache 27 + feedback.FeedbackIO // should this be pulled out? 28 28 } 29 29 30 30 func IO(c cache.Cache) recipeio {
+21
internal/recipes/mock.go
··· 1 1 package recipes 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "fmt" 6 7 "log/slog" ··· 13 14 ) 14 15 15 16 type mock struct{} 17 + 18 + var mockRecipeImage = []byte{ 19 + 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 20 + 0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R', 21 + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 22 + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 23 + 0x89, 0x00, 0x00, 0x00, 0x0d, 'I', 'D', 'A', 24 + 'T', 0x78, 0x9c, 0x63, 0xf8, 0xff, 0xff, 0x3f, 25 + 0x00, 0x05, 0xfe, 0x02, 0xfe, 0xa7, 0x35, 0x81, 26 + 0x84, 0x00, 0x00, 0x00, 0x00, 'I', 'E', 'N', 27 + 'D', 0xae, 'B', 0x60, 0x82, 28 + } 16 29 17 30 var mockRecipes = []ai.Recipe{ 18 31 { ··· 396 409 func (m mock) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 397 410 _ = conversationID 398 411 return fmt.Sprintf("Mock answer: %s", question), nil 412 + } 413 + 414 + func (m mock) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 415 + _ = ctx 416 + _ = recipe 417 + return &ai.GeneratedImage{ 418 + Body: bytes.NewReader(mockRecipeImage), 419 + }, nil 399 420 } 400 421 401 422 func (m mock) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) {
+4 -4
internal/recipes/selection.go
··· 68 68 return selection 69 69 } 70 70 71 - func (s *server) loadRecipeSelection(ctx context.Context, userID, originHash string) (recipeSelection, error) { 72 - reader, err := s.Cache.Get(ctx, recipeSelectionKey(userID, originHash)) 71 + func (rio recipeio) loadRecipeSelection(ctx context.Context, userID, originHash string) (recipeSelection, error) { 72 + reader, err := rio.Cache.Get(ctx, recipeSelectionKey(userID, originHash)) 73 73 if err != nil { 74 74 if errors.Is(err, cache.ErrNotFound) { 75 75 return recipeSelection{}, nil ··· 87 87 return selection, nil 88 88 } 89 89 90 - func (s *server) saveRecipeSelection(ctx context.Context, userID, originHash string, selection recipeSelection) error { 90 + func (rio recipeio) saveRecipeSelection(ctx context.Context, userID, originHash string, selection recipeSelection) error { 91 91 selection.UpdatedAt = time.Now() 92 92 body, err := json.Marshal(selection) 93 93 if err != nil { 94 94 return fmt.Errorf("failed to marshal recipe selection: %w", err) 95 95 } 96 96 // good place for etags :) 97 - if err := s.Cache.Put(ctx, recipeSelectionKey(userID, originHash), string(body), cache.Unconditional()); err != nil { 97 + if err := rio.Cache.Put(ctx, recipeSelectionKey(userID, originHash), string(body), cache.Unconditional()); err != nil { 98 98 return fmt.Errorf("failed to save recipe selection: %w", err) 99 99 } 100 100 return nil
+133 -5
internal/recipes/server.go
··· 1 1 package recipes 2 2 3 3 import ( 4 + "bufio" 4 5 "bytes" 5 6 "context" 6 7 "encoding/base64" 7 8 "errors" 8 9 "fmt" 9 10 "html/template" 11 + "io" 10 12 "log/slog" 11 13 "net/http" 12 14 "net/url" ··· 73 75 type generator interface { 74 76 GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) 75 77 AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 78 + GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) 76 79 PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) 77 80 Ready(ctx context.Context) error 78 81 } 79 82 80 83 type server struct { 81 84 recipeio 85 + imageio 82 86 cfg *config.Config 83 87 storage *users.Storage 84 - cache cache.Cache 85 88 generator generator 86 89 locServer locServer 87 90 wg sync.WaitGroup ··· 90 93 91 94 // NewHandler returns an http.Handler serving the recipe endpoints under /recipes. 92 95 // cache must be connected to generator or this will not work. Should we enfroce that by getting cache from generator? 93 - func NewHandler(cfg *config.Config, storage *users.Storage, generator generator, locServer locServer, c cache.Cache, clerkClient auth.AuthClient) *server { 96 + func NewHandler(cfg *config.Config, storage *users.Storage, generator generator, locServer locServer, c cache.Cache, imageCache cache.Cache, clerkClient auth.AuthClient) *server { 94 97 return &server{ 95 98 recipeio: IO(c), 96 - cache: c, 99 + imageio: imageio{Cache: imageCache}, 97 100 cfg: cfg, 98 101 storage: storage, 99 102 generator: generator, ··· 102 105 } 103 106 } 104 107 108 + func (s *server) recipeImageIO() imageio { 109 + if s.imageio.Cache != nil { 110 + return s.imageio 111 + } 112 + return imageio{Cache: s.recipeio.Cache} 113 + } 114 + 115 + func (s *server) RecipeImageExists(ctx context.Context, hash string) (bool, error) { 116 + return s.recipeImageIO().RecipeImageExists(ctx, hash) 117 + } 118 + 119 + func (s *server) RecipeImageFromCache(ctx context.Context, hash string) (io.ReadCloser, error) { 120 + return s.recipeImageIO().RecipeImageFromCache(ctx, hash) 121 + } 122 + 123 + func (s *server) SaveRecipeImage(ctx context.Context, hash string, image *ai.GeneratedImage) error { 124 + return s.recipeImageIO().SaveRecipeImage(ctx, hash, image) 125 + } 126 + 105 127 func (s *server) Register(mux routing.Registrar) { 106 128 mux.HandleFunc("GET /recipes", s.handleRecipes) 107 129 mux.HandleFunc("POST /recipes/{hash}/regenerate", s.handleRegenerate) 108 130 mux.HandleFunc("POST /recipes/{hash}/finalize", s.handleFinalize) 109 131 mux.HandleFunc("GET /recipe/{hash}", s.handleSingle) 132 + mux.HandleFunc("GET /recipe/{hash}/image", s.handleRecipeImage) 133 + mux.HandleFunc("POST /recipe/{hash}/image", s.handleGenerateRecipeImage) 110 134 mux.HandleFunc("POST /recipe/{hash}/question", s.handleQuestion) 111 135 mux.HandleFunc("POST /recipe/{hash}/wine", s.handleWine) 112 136 mux.HandleFunc("POST /recipe/{hash}/feedback", s.handleFeedback) ··· 136 160 feedback := feedback.Feedback{} 137 161 var thread []RecipeThreadEntry 138 162 var wineRecommendation *ai.WineSelection 163 + var hasRecipeImage bool 139 164 var loadWG sync.WaitGroup 140 165 loadWG.Go(func() { 141 166 existing, err := s.FeedbackFromCache(ctx, hash) ··· 167 192 } 168 193 wineRecommendation = selection 169 194 }) 195 + loadWG.Go(func() { 196 + exists, err := s.RecipeImageExists(ctx, hash) 197 + if err != nil { 198 + slog.ErrorContext(ctx, "failed to check cached recipe image", "hash", hash, "error", err) 199 + return 200 + } 201 + hasRecipeImage = exists 202 + }) 170 203 loadWG.Wait() 171 204 172 205 if recipe.OriginHash == "" { ··· 175 208 ID: "", 176 209 Name: "Unknown Location", 177 210 }, time.Now()) 178 - FormatRecipeHTML(ctx, p, *recipe, signedIn, thread, feedback, wineRecommendation, w) 211 + FormatRecipeHTML(ctx, p, *recipe, signedIn, hasRecipeImage, thread, feedback, wineRecommendation, w) 179 212 return 180 213 } 181 214 // we didn't go back and update old recipes's with new hash so have to handle that here. Could still backfill ··· 204 237 } 205 238 206 239 slog.InfoContext(ctx, "serving shared recipe by hash", "hash", hash, "signedIn", signedIn) 207 - FormatRecipeHTML(ctx, p, *recipe, signedIn, thread, feedback, wineRecommendation, w) 240 + FormatRecipeHTML(ctx, p, *recipe, signedIn, hasRecipeImage, thread, feedback, wineRecommendation, w) 241 + } 242 + 243 + func (s *server) handleRecipeImage(w http.ResponseWriter, r *http.Request) { 244 + ctx := r.Context() 245 + hash := strings.TrimSpace(r.PathValue("hash")) 246 + if hash == "" { 247 + http.Error(w, "missing recipe hash", http.StatusBadRequest) 248 + return 249 + } 250 + 251 + imageBody, err := s.RecipeImageFromCache(ctx, hash) 252 + if err != nil { 253 + if errors.Is(err, cache.ErrNotFound) { 254 + http.Error(w, "recipe image not found", http.StatusNotFound) 255 + return 256 + } 257 + slog.ErrorContext(ctx, "failed to load cached recipe image", "hash", hash, "error", err) 258 + http.Error(w, "failed to load recipe image", http.StatusInternalServerError) 259 + return 260 + } 261 + defer func() { 262 + if err := imageBody.Close(); err != nil { 263 + slog.ErrorContext(ctx, "failed to close cached recipe image", "hash", hash, "error", err) 264 + } 265 + }() 266 + 267 + imageReader := bufio.NewReader(imageBody) 268 + header, err := imageReader.Peek(512) 269 + if err != nil && !errors.Is(err, bufio.ErrBufferFull) && !errors.Is(err, io.EOF) { 270 + slog.ErrorContext(ctx, "failed to sniff cached recipe image", "hash", hash, "error", err) 271 + http.Error(w, "failed to load recipe image", http.StatusInternalServerError) 272 + return 273 + } 274 + 275 + w.Header().Set("Content-Type", http.DetectContentType(header)) 276 + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 277 + if _, err := io.Copy(w, imageReader); err != nil { 278 + slog.ErrorContext(ctx, "failed to stream cached recipe image", "hash", hash, "error", err) 279 + } 280 + } 281 + 282 + func (s *server) handleGenerateRecipeImage(w http.ResponseWriter, r *http.Request) { 283 + ctx := r.Context() 284 + if !isHTMXRequest(r) { 285 + http.Error(w, "htmx request required", http.StatusBadRequest) 286 + return 287 + } 288 + hash := strings.TrimSpace(r.PathValue("hash")) 289 + if hash == "" { 290 + http.Error(w, "missing recipe hash", http.StatusBadRequest) 291 + return 292 + } 293 + if _, err := s.clerk.GetUserIDFromRequest(r); err != nil { 294 + redirectToSignIn(w, r, http.StatusUnauthorized) 295 + return 296 + } 297 + 298 + hasRecipeImage, err := s.RecipeImageExists(ctx, hash) 299 + if err != nil { 300 + slog.ErrorContext(ctx, "failed to check cached recipe image", "hash", hash, "error", err) 301 + http.Error(w, "failed to load recipe image", http.StatusInternalServerError) 302 + return 303 + } 304 + if !hasRecipeImage { 305 + recipe, err := s.SingleFromCache(ctx, hash) 306 + if err != nil { 307 + if errors.Is(err, cache.ErrNotFound) { 308 + http.Error(w, "recipe not found", http.StatusNotFound) 309 + return 310 + } 311 + slog.ErrorContext(ctx, "failed to load recipe for image generation", "hash", hash, "error", err) 312 + http.Error(w, "failed to load recipe", http.StatusInternalServerError) 313 + return 314 + } 315 + 316 + // this like wine is slow should we kick it into a go routine and poll? https://htmx.org/docs/#load_polling same for wine? 317 + s.wg.Add(1) 318 + defer s.wg.Done() 319 + generationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 90*time.Second) 320 + defer cancel() 321 + image, err := s.generator.GenerateRecipeImage(generationCtx, *recipe) 322 + if err != nil { 323 + slog.ErrorContext(ctx, "failed to generate recipe image", "hash", hash, "error", err) 324 + http.Error(w, "failed to generate recipe image", http.StatusInternalServerError) 325 + return 326 + } 327 + if err := s.SaveRecipeImage(generationCtx, hash, image); err != nil { 328 + slog.ErrorContext(ctx, "failed to save recipe image", "hash", hash, "error", err) 329 + http.Error(w, "failed to cache recipe image", http.StatusInternalServerError) 330 + return 331 + } 332 + hasRecipeImage = true 333 + } 334 + 335 + FormatRecipeImageActionResponseHTML(hash, true, hasRecipeImage, w) 208 336 } 209 337 210 338 func (s *server) handleQuestion(w http.ResponseWriter, r *http.Request) {
+140 -2
internal/recipes/server_test.go
··· 501 501 if !strings.Contains(body, "Balances the rich chicken skin.") { 502 502 t.Fatalf("expected cached wine commentary in response, got body: %s", body) 503 503 } 504 - if strings.Contains(body, "choose a wine") { 505 - t.Fatalf("expected no choose-a-wine button when cached recommendation exists, got body: %s", body) 504 + if strings.Contains(body, "Choose a wine") { 505 + t.Fatalf("expected cached recommendation to replace the wine picker, got body: %s", body) 506 506 } 507 507 } 508 508 ··· 599 599 panic("unexpected call to AskQuestion") 600 600 } 601 601 602 + func (c *captureKickgenerationGenerator) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 603 + panic("unexpected call to GenerateRecipeImage") 604 + } 605 + 602 606 func (c *captureKickgenerationGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 603 607 panic("unexpected call to PickAWine") 604 608 } ··· 682 686 wineRecommendation string 683 687 winePickCalls int 684 688 panicOnWine bool 689 + imageCalls int 690 + panicOnImage bool 691 + imageBody []byte 685 692 } 686 693 687 694 func (c *captureQuestionGenerator) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) { ··· 691 698 func (c *captureQuestionGenerator) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 692 699 c.lastQuestion = question 693 700 return "Try chicken thighs at the same cook time.", nil 701 + } 702 + 703 + func (c *captureQuestionGenerator) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 704 + if c.panicOnImage { 705 + panic("unexpected call to GenerateRecipeImage") 706 + } 707 + _ = ctx 708 + _ = recipe 709 + c.imageCalls++ 710 + body := c.imageBody 711 + if len(body) == 0 { 712 + body = []byte("webp-bytes") 713 + } 714 + return &ai.GeneratedImage{ 715 + Body: bytes.NewReader(body), 716 + }, nil 694 717 } 695 718 696 719 func (c *captureQuestionGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { ··· 1033 1056 } 1034 1057 if got, want := g2.winePickCalls, 0; got != want { 1035 1058 t.Fatalf("expected PickAWine call count %d, got %d", want, got) 1059 + } 1060 + } 1061 + 1062 + func TestHandleRecipeImage_ServesCachedImageWithoutGenerator(t *testing.T) { 1063 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1064 + g := &captureQuestionGenerator{panicOnImage: true} 1065 + s := &server{ 1066 + recipeio: IO(cacheStore), 1067 + storage: users.NewStorage(cacheStore), 1068 + clerk: auth.DefaultMock(), 1069 + generator: g, 1070 + } 1071 + 1072 + recipe := ai.Recipe{ 1073 + Title: "Roast Chicken", 1074 + Description: "Crisp skin and herbs.", 1075 + Ingredients: []ai.Ingredient{{Name: "chicken", Quantity: "1", Price: "$12"}}, 1076 + Instructions: []string{"Roast until done."}, 1077 + } 1078 + recipeHash := recipe.ComputeHash() 1079 + imageBody := []byte{'R', 'I', 'F', 'F', 0x24, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P', 'V', 'P', '8', ' '} 1080 + if err := s.SaveRecipeImage(t.Context(), recipeHash, &ai.GeneratedImage{Body: bytes.NewReader(imageBody)}); err != nil { 1081 + t.Fatalf("failed to seed recipe image: %v", err) 1082 + } 1083 + 1084 + req := httptest.NewRequest(http.MethodGet, "/recipe/"+recipeHash+"/image", nil) 1085 + req.SetPathValue("hash", recipeHash) 1086 + rr := httptest.NewRecorder() 1087 + 1088 + s.handleRecipeImage(rr, req) 1089 + 1090 + if rr.Code != http.StatusOK { 1091 + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, rr.Code, rr.Body.String()) 1092 + } 1093 + if got, want := rr.Header().Get("Content-Type"), http.DetectContentType(imageBody); got != want { 1094 + t.Fatalf("expected %q content type, got %q", want, got) 1095 + } 1096 + if got := rr.Body.Bytes(); !bytes.Equal(got, imageBody) { 1097 + t.Fatalf("expected cached image bytes %v, got %v", imageBody, got) 1098 + } 1099 + if got, want := g.imageCalls, 0; got != want { 1100 + t.Fatalf("expected GenerateRecipeImage call count %d, got %d", want, got) 1101 + } 1102 + } 1103 + 1104 + func TestHandleGenerateRecipeImage_GeneratesAndCachesOnMiss(t *testing.T) { 1105 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1106 + g := &captureQuestionGenerator{imageBody: []byte("generated-image-bytes")} 1107 + s := &server{ 1108 + recipeio: IO(cacheStore), 1109 + storage: users.NewStorage(cacheStore), 1110 + clerk: auth.DefaultMock(), 1111 + generator: g, 1112 + } 1113 + 1114 + recipe := ai.Recipe{ 1115 + Title: "Roast Chicken", 1116 + Description: "Crisp skin and herbs.", 1117 + Ingredients: []ai.Ingredient{{Name: "chicken", Quantity: "1", Price: "$12"}}, 1118 + Instructions: []string{"Roast until done."}, 1119 + } 1120 + recipeHash := recipe.ComputeHash() 1121 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, "origin-hash"); err != nil { 1122 + t.Fatalf("failed to save recipe: %v", err) 1123 + } 1124 + 1125 + req1 := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/image", nil) 1126 + req1.Header.Set("HX-Request", "true") 1127 + req1.SetPathValue("hash", recipeHash) 1128 + rr1 := httptest.NewRecorder() 1129 + 1130 + s.handleGenerateRecipeImage(rr1, req1) 1131 + 1132 + if rr1.Code != http.StatusOK { 1133 + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, rr1.Code, rr1.Body.String()) 1134 + } 1135 + body := rr1.Body.String() 1136 + if !strings.Contains(body, `id="recipe-image-action"`) { 1137 + t.Fatalf("expected recipe image action fragment, got body: %s", body) 1138 + } 1139 + if !strings.Contains(body, `id="recipe-image-panel"`) || !strings.Contains(body, `hx-swap-oob="outerHTML"`) { 1140 + t.Fatalf("expected recipe image panel out-of-band update, got body: %s", body) 1141 + } 1142 + if !strings.Contains(body, "/recipe/"+recipeHash+"/image") { 1143 + t.Fatalf("expected recipe image URL in response, got body: %s", body) 1144 + } 1145 + if got, want := g.imageCalls, 1; got != want { 1146 + t.Fatalf("expected GenerateRecipeImage call count %d, got %d", want, got) 1147 + } 1148 + 1149 + req2 := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/image", nil) 1150 + req2.Header.Set("HX-Request", "true") 1151 + req2.SetPathValue("hash", recipeHash) 1152 + rr2 := httptest.NewRecorder() 1153 + 1154 + s.handleGenerateRecipeImage(rr2, req2) 1155 + 1156 + if rr2.Code != http.StatusOK { 1157 + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, rr2.Code, rr2.Body.String()) 1158 + } 1159 + if got, want := g.imageCalls, 1; got != want { 1160 + t.Fatalf("expected cached image to avoid extra generation; got %d calls", got) 1161 + } 1162 + 1163 + req3 := httptest.NewRequest(http.MethodGet, "/recipe/"+recipeHash+"/image", nil) 1164 + req3.SetPathValue("hash", recipeHash) 1165 + rr3 := httptest.NewRecorder() 1166 + 1167 + s.handleRecipeImage(rr3, req3) 1168 + 1169 + if rr3.Code != http.StatusOK { 1170 + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, rr3.Code, rr3.Body.String()) 1171 + } 1172 + if got := rr3.Body.String(); got != "generated-image-bytes" { 1173 + t.Fatalf("expected cached generated image body, got %q", got) 1036 1174 } 1037 1175 } 1038 1176
+5 -23
internal/recipes/wine.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "io" 8 - "log/slog" 9 7 10 8 "careme/internal/ai" 11 9 "careme/internal/cache" ··· 18 16 } 19 17 20 18 func (rio recipeio) WineFromCache(ctx context.Context, hash string) (*ai.WineSelection, error) { 21 - body, err := rio.readBytesFromCache(ctx, recipeWineCacheKey(hash)) 19 + wineReader, err := rio.Cache.Get(ctx, recipeWineCacheKey(hash)) 22 20 if err != nil { 23 21 return nil, err 24 22 } 25 - 23 + defer func() { 24 + _ = wineReader.Close() 25 + }() 26 26 var selection ai.WineSelection 27 - err = json.Unmarshal(body, &selection) 27 + err = json.NewDecoder(wineReader).Decode(&selection) 28 28 return &selection, err 29 29 } 30 30 ··· 38 38 } 39 39 return rio.Cache.Put(ctx, recipeWineCacheKey(hash), string(body), cache.Unconditional()) 40 40 } 41 - 42 - func (rio recipeio) readBytesFromCache(ctx context.Context, key string) ([]byte, error) { 43 - reader, err := rio.Cache.Get(ctx, key) 44 - if err != nil { 45 - return nil, err 46 - } 47 - defer func() { 48 - if err := reader.Close(); err != nil { 49 - slog.ErrorContext(ctx, "failed to close cached string reader", "key", key, "error", err) 50 - } 51 - }() 52 - 53 - body, err := io.ReadAll(reader) 54 - if err != nil { 55 - return nil, err 56 - } 57 - return body, nil 58 - }
+1 -1
internal/static/tailwind.css
··· 1 1 /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ 2 - @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-700:oklch(50.5% .213 27.518);--color-amber-500:oklch(76.9% .188 70.08);--color-green-700:oklch(52.7% .154 150.069);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-700:oklch(50.8% .118 165.612);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand-50:var(--brand-50);--color-brand-100:var(--brand-100);--color-brand-200:var(--brand-200);--color-brand-300:var(--brand-300);--color-brand-400:var(--brand-400);--color-brand-500:var(--brand-500);--color-brand-600:var(--brand-600);--color-brand-700:var(--brand-700);--color-brand-800:var(--brand-800);--color-brand-900:var(--brand-900);--color-ink-500:#64748b;--color-ink-600:#475569;--color-ink-700:#334155;--color-ink-900:#0f172a;--color-action-green-50:#ecfdf5;--color-action-green-100:#d1fae5;--color-action-green-300:#6ee7b7;--color-action-green-500:#10b981;--color-action-green-600:#059669;--color-action-green-700:#047857;--color-action-red-50:#fef2f2;--color-action-red-100:#fee2e2;--color-action-red-500:#ef4444;--color-action-red-600:#dc2626;--color-action-red-700:#b91c1c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}p{text-wrap:pretty}}@layer components{.friendly-card{corner-shape:squircle;border-radius:48px}.friendly-card-soft{corner-shape:squircle;border-radius:28px}.shopping-wine-details,.shopping-recipe-card:has(details[open]) .shopping-wine-preview{display:none}.shopping-recipe-card:has(details[open]) .shopping-wine-details{display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.top-3{top:calc(var(--spacing)*3)}.right-0{right:calc(var(--spacing)*0)}.z-10{z-index:10}.z-20{z-index:20}.-mx-2{margin-inline:calc(var(--spacing)*-2)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-10{margin-top:calc(var(--spacing)*10)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-4{height:calc(var(--spacing)*4)}.h-14{height:calc(var(--spacing)*14)}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing)*4)}.w-14{width:calc(var(--spacing)*14)}.w-48{width:calc(var(--spacing)*48)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.flex-1{flex:1}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing)*2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-brand-100>:not(:last-child)){border-color:var(--color-brand-100)}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-action-green-500{border-color:var(--color-action-green-500)}.border-action-red-500{border-color:var(--color-action-red-500)}.border-brand-100{border-color:var(--color-brand-100)}.border-brand-200{border-color:var(--color-brand-200)}.border-brand-300{border-color:var(--color-brand-300)}.border-brand-400{border-color:var(--color-brand-400)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-t-brand-600{border-top-color:var(--color-brand-600)}.bg-action-green-50{background-color:var(--color-action-green-50)}.bg-action-green-500{background-color:var(--color-action-green-500)}.bg-action-red-50{background-color:var(--color-action-red-50)}.bg-brand-50,.bg-brand-50\/40{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/40{background-color:color-mix(in oklab,var(--color-brand-50)40%,transparent)}}.bg-brand-50\/60{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/60{background-color:color-mix(in oklab,var(--color-brand-50)60%,transparent)}}.bg-brand-50\/70{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/70{background-color:color-mix(in oklab,var(--color-brand-50)70%,transparent)}}.bg-brand-50\/80{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/80{background-color:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.bg-brand-100{background-color:var(--color-brand-100)}.bg-brand-500{background-color:var(--color-brand-500)}.bg-brand-600{background-color:var(--color-brand-600)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white)60%,transparent)}}.bg-white\/88{background-color:#ffffffe0}@supports (color:color-mix(in lab, red, red)){.bg-white\/88{background-color:color-mix(in oklab,var(--color-white)88%,transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab,var(--color-white)95%,transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-brand-50{--tw-gradient-from:var(--color-brand-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-brand-50\/80{--tw-gradient-from:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.from-brand-50\/80{--tw-gradient-from:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.from-brand-50\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-white\/72{--tw-gradient-from:#ffffffb8}@supports (color:color-mix(in lab, red, red)){.from-white\/72{--tw-gradient-from:color-mix(in oklab,var(--color-white)72%,transparent)}}.from-white\/72{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.via-white{--tw-gradient-via:var(--color-white);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-white\/62{--tw-gradient-via:#ffffff9e}@supports (color:color-mix(in lab, red, red)){.via-white\/62{--tw-gradient-via:color-mix(in oklab,var(--color-white)62%,transparent)}}.via-white\/62{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-brand-50\/80{--tw-gradient-to:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.to-brand-50\/80{--tw-gradient-to:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.to-brand-50\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white\/78{--tw-gradient-to:#ffffffc7}@supports (color:color-mix(in lab, red, red)){.to-white\/78{--tw-gradient-to:color-mix(in oklab,var(--color-white)78%,transparent)}}.to-white\/78{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.object-cover{object-fit:cover}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-10{padding:calc(var(--spacing)*10)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-8{padding-top:calc(var(--spacing)*8)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-6{padding-left:calc(var(--spacing)*6)}.text-center{text-align:center}.text-left{text-align:left}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-pretty{text-wrap:pretty}.whitespace-pre-line{white-space:pre-line}.text-action-green-700{color:var(--color-action-green-700)}.text-action-red-600{color:var(--color-action-red-600)}.text-action-red-700{color:var(--color-action-red-700)}.text-amber-500{color:var(--color-amber-500)}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-brand-800{color:var(--color-brand-800)}.text-brand-900{color:var(--color-brand-900)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-ink-500{color:var(--color-ink-500)}.text-ink-600{color:var(--color-ink-600)}.text-ink-700{color:var(--color-ink-700)}.text-ink-900{color:var(--color-ink-900)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.decoration-brand-300{-webkit-text-decoration-color:var(--color-brand-300);-webkit-text-decoration-color:var(--color-brand-300);text-decoration-color:var(--color-brand-300)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.opacity-60{opacity:.6}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.peer-checked\/dismiss\:border-action-red-700:is(:where(.peer\/dismiss):checked~*){border-color:var(--color-action-red-700)}.peer-checked\/dismiss\:bg-action-red-600:is(:where(.peer\/dismiss):checked~*){background-color:var(--color-action-red-600)}.peer-checked\/dismiss\:text-white:is(:where(.peer\/dismiss):checked~*){color:var(--color-white)}.peer-checked\/instructions\:block:is(:where(.peer\/instructions):checked~*){display:block}.peer-checked\/save\:border-action-green-700:is(:where(.peer\/save):checked~*){border-color:var(--color-action-green-700)}.peer-checked\/save\:bg-action-green-600:is(:where(.peer\/save):checked~*){background-color:var(--color-action-green-600)}.peer-checked\/save\:text-white:is(:where(.peer\/save):checked~*){color:var(--color-white)}@media (hover:hover){.hover\:border-brand-400:hover{border-color:var(--color-brand-400)}.hover\:bg-action-green-100:hover{background-color:var(--color-action-green-100)}.hover\:bg-action-green-600:hover{background-color:var(--color-action-green-600)}.hover\:bg-action-red-50:hover{background-color:var(--color-action-red-50)}.hover\:bg-action-red-100:hover{background-color:var(--color-action-red-100)}.hover\:bg-brand-50:hover{background-color:var(--color-brand-50)}.hover\:bg-brand-100:hover{background-color:var(--color-brand-100)}.hover\:bg-brand-200:hover{background-color:var(--color-brand-200)}.hover\:bg-brand-600:hover{background-color:var(--color-brand-600)}.hover\:bg-brand-700:hover{background-color:var(--color-brand-700)}.hover\:bg-white\/70:hover{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/70:hover{background-color:color-mix(in oklab,var(--color-white)70%,transparent)}}.hover\:text-amber-500:hover{color:var(--color-amber-500)}.hover\:text-brand-600:hover{color:var(--color-brand-600)}.hover\:text-brand-700:hover{color:var(--color-brand-700)}.hover\:text-brand-800:hover{color:var(--color-brand-800)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-brand-400:focus{border-color:var(--color-brand-400)}.focus\:border-brand-500:focus{border-color:var(--color-brand-500)}.focus\:bg-brand-50:focus{background-color:var(--color-brand-50)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-action-green-300:focus{--tw-ring-color:var(--color-action-green-300)}.focus\:ring-brand-300:focus{--tw-ring-color:var(--color-brand-300)}.focus\:ring-brand-400:focus{--tw-ring-color:var(--color-brand-400)}.focus\:ring-gray-300:focus{--tw-ring-color:var(--color-gray-300)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-brand-400:disabled{background-color:var(--color-brand-400)}.disabled\:text-brand-400:disabled{color:var(--color-brand-400)}.disabled\:text-white\/90:disabled{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.disabled\:text-white\/90:disabled{color:color-mix(in oklab,var(--color-white)90%,transparent)}}@media (min-width:40rem){.sm\:w-40{width:calc(var(--spacing)*40)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:py-10{padding-block:calc(var(--spacing)*10)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@supports (color:oklch(62% 0.12 220)){:root{--color-ink-500:oklch(55% .032 248);--color-ink-600:oklch(46% .034 248);--color-ink-700:oklch(38.5% .035 250);--color-ink-900:oklch(24% .033 254);--color-action-green-50:oklch(97.6% .02 154);--color-action-green-100:oklch(94.5% .042 154);--color-action-green-300:oklch(82.8% .111 154);--color-action-green-500:oklch(68.5% .169 154);--color-action-green-600:oklch(58.8% .158 154);--color-action-green-700:oklch(50% .134 154);--color-action-red-50:oklch(97.6% .02 28);--color-action-red-100:oklch(94.5% .042 28);--color-action-red-300:oklch(82.8% .111 28);--color-action-red-500:oklch(68.5% .169 28);--color-action-red-600:oklch(58.8% .158 28);--color-action-red-700:oklch(50% .134 28)}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}} 2 + @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-700:oklch(50.5% .213 27.518);--color-amber-500:oklch(76.9% .188 70.08);--color-green-700:oklch(52.7% .154 150.069);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-700:oklch(50.8% .118 165.612);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand-50:var(--brand-50);--color-brand-100:var(--brand-100);--color-brand-200:var(--brand-200);--color-brand-300:var(--brand-300);--color-brand-400:var(--brand-400);--color-brand-500:var(--brand-500);--color-brand-600:var(--brand-600);--color-brand-700:var(--brand-700);--color-brand-800:var(--brand-800);--color-brand-900:var(--brand-900);--color-ink-500:#64748b;--color-ink-600:#475569;--color-ink-700:#334155;--color-ink-900:#0f172a;--color-action-green-50:#ecfdf5;--color-action-green-100:#d1fae5;--color-action-green-300:#6ee7b7;--color-action-green-500:#10b981;--color-action-green-600:#059669;--color-action-green-700:#047857;--color-action-red-50:#fef2f2;--color-action-red-100:#fee2e2;--color-action-red-500:#ef4444;--color-action-red-600:#dc2626;--color-action-red-700:#b91c1c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}p{text-wrap:pretty}}@layer components{.friendly-card{corner-shape:squircle;border-radius:48px}.friendly-card-soft{corner-shape:squircle;border-radius:28px}.shopping-wine-details,.shopping-recipe-card:has(details[open]) .shopping-wine-preview{display:none}.shopping-recipe-card:has(details[open]) .shopping-wine-details{display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.top-3{top:calc(var(--spacing)*3)}.right-0{right:calc(var(--spacing)*0)}.z-10{z-index:10}.z-20{z-index:20}.-mx-2{margin-inline:calc(var(--spacing)*-2)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-10{margin-top:calc(var(--spacing)*10)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-4{height:calc(var(--spacing)*4)}.h-14{height:calc(var(--spacing)*14)}.h-auto{height:auto}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing)*4)}.w-14{width:calc(var(--spacing)*14)}.w-48{width:calc(var(--spacing)*48)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.flex-1{flex:1}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing)*2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-brand-100>:not(:last-child)){border-color:var(--color-brand-100)}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-action-green-500{border-color:var(--color-action-green-500)}.border-action-red-500{border-color:var(--color-action-red-500)}.border-brand-100{border-color:var(--color-brand-100)}.border-brand-200{border-color:var(--color-brand-200)}.border-brand-300{border-color:var(--color-brand-300)}.border-brand-400{border-color:var(--color-brand-400)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-t-brand-600{border-top-color:var(--color-brand-600)}.bg-action-green-50{background-color:var(--color-action-green-50)}.bg-action-green-500{background-color:var(--color-action-green-500)}.bg-action-red-50{background-color:var(--color-action-red-50)}.bg-brand-50,.bg-brand-50\/40{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/40{background-color:color-mix(in oklab,var(--color-brand-50)40%,transparent)}}.bg-brand-50\/60{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/60{background-color:color-mix(in oklab,var(--color-brand-50)60%,transparent)}}.bg-brand-50\/70{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/70{background-color:color-mix(in oklab,var(--color-brand-50)70%,transparent)}}.bg-brand-50\/80{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/80{background-color:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.bg-brand-100{background-color:var(--color-brand-100)}.bg-brand-500{background-color:var(--color-brand-500)}.bg-brand-600{background-color:var(--color-brand-600)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white)60%,transparent)}}.bg-white\/88{background-color:#ffffffe0}@supports (color:color-mix(in lab, red, red)){.bg-white\/88{background-color:color-mix(in oklab,var(--color-white)88%,transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab,var(--color-white)95%,transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-brand-50{--tw-gradient-from:var(--color-brand-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-brand-50\/80{--tw-gradient-from:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.from-brand-50\/80{--tw-gradient-from:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.from-brand-50\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-white\/72{--tw-gradient-from:#ffffffb8}@supports (color:color-mix(in lab, red, red)){.from-white\/72{--tw-gradient-from:color-mix(in oklab,var(--color-white)72%,transparent)}}.from-white\/72{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.via-white{--tw-gradient-via:var(--color-white);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-white\/62{--tw-gradient-via:#ffffff9e}@supports (color:color-mix(in lab, red, red)){.via-white\/62{--tw-gradient-via:color-mix(in oklab,var(--color-white)62%,transparent)}}.via-white\/62{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-brand-50\/80{--tw-gradient-to:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.to-brand-50\/80{--tw-gradient-to:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.to-brand-50\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white\/78{--tw-gradient-to:#ffffffc7}@supports (color:color-mix(in lab, red, red)){.to-white\/78{--tw-gradient-to:color-mix(in oklab,var(--color-white)78%,transparent)}}.to-white\/78{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.object-cover{object-fit:cover}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-10{padding:calc(var(--spacing)*10)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-8{padding-top:calc(var(--spacing)*8)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-6{padding-left:calc(var(--spacing)*6)}.text-center{text-align:center}.text-left{text-align:left}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-pretty{text-wrap:pretty}.whitespace-pre-line{white-space:pre-line}.text-action-green-700{color:var(--color-action-green-700)}.text-action-red-600{color:var(--color-action-red-600)}.text-action-red-700{color:var(--color-action-red-700)}.text-amber-500{color:var(--color-amber-500)}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-brand-800{color:var(--color-brand-800)}.text-brand-900{color:var(--color-brand-900)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-ink-500{color:var(--color-ink-500)}.text-ink-600{color:var(--color-ink-600)}.text-ink-700{color:var(--color-ink-700)}.text-ink-900{color:var(--color-ink-900)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.decoration-brand-300{-webkit-text-decoration-color:var(--color-brand-300);-webkit-text-decoration-color:var(--color-brand-300);text-decoration-color:var(--color-brand-300)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.opacity-25{opacity:.25}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.peer-checked\/dismiss\:border-action-red-700:is(:where(.peer\/dismiss):checked~*){border-color:var(--color-action-red-700)}.peer-checked\/dismiss\:bg-action-red-600:is(:where(.peer\/dismiss):checked~*){background-color:var(--color-action-red-600)}.peer-checked\/dismiss\:text-white:is(:where(.peer\/dismiss):checked~*){color:var(--color-white)}.peer-checked\/instructions\:block:is(:where(.peer\/instructions):checked~*){display:block}.peer-checked\/save\:border-action-green-700:is(:where(.peer\/save):checked~*){border-color:var(--color-action-green-700)}.peer-checked\/save\:bg-action-green-600:is(:where(.peer\/save):checked~*){background-color:var(--color-action-green-600)}.peer-checked\/save\:text-white:is(:where(.peer\/save):checked~*){color:var(--color-white)}@media (hover:hover){.hover\:border-brand-400:hover{border-color:var(--color-brand-400)}.hover\:bg-action-green-100:hover{background-color:var(--color-action-green-100)}.hover\:bg-action-green-600:hover{background-color:var(--color-action-green-600)}.hover\:bg-action-red-50:hover{background-color:var(--color-action-red-50)}.hover\:bg-action-red-100:hover{background-color:var(--color-action-red-100)}.hover\:bg-brand-50:hover{background-color:var(--color-brand-50)}.hover\:bg-brand-100:hover{background-color:var(--color-brand-100)}.hover\:bg-brand-200:hover{background-color:var(--color-brand-200)}.hover\:bg-brand-600:hover{background-color:var(--color-brand-600)}.hover\:bg-brand-700:hover{background-color:var(--color-brand-700)}.hover\:bg-white\/70:hover{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/70:hover{background-color:color-mix(in oklab,var(--color-white)70%,transparent)}}.hover\:text-amber-500:hover{color:var(--color-amber-500)}.hover\:text-brand-600:hover{color:var(--color-brand-600)}.hover\:text-brand-700:hover{color:var(--color-brand-700)}.hover\:text-brand-800:hover{color:var(--color-brand-800)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-brand-400:focus{border-color:var(--color-brand-400)}.focus\:border-brand-500:focus{border-color:var(--color-brand-500)}.focus\:bg-brand-50:focus{background-color:var(--color-brand-50)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-action-green-300:focus{--tw-ring-color:var(--color-action-green-300)}.focus\:ring-brand-300:focus{--tw-ring-color:var(--color-brand-300)}.focus\:ring-brand-400:focus{--tw-ring-color:var(--color-brand-400)}.focus\:ring-gray-300:focus{--tw-ring-color:var(--color-gray-300)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-brand-400:disabled{background-color:var(--color-brand-400)}.disabled\:text-brand-400:disabled{color:var(--color-brand-400)}.disabled\:text-white\/90:disabled{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.disabled\:text-white\/90:disabled{color:color-mix(in oklab,var(--color-white)90%,transparent)}}@media (min-width:40rem){.sm\:w-40{width:calc(var(--spacing)*40)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:py-10{padding-block:calc(var(--spacing)*10)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@supports (color:oklch(62% 0.12 220)){:root{--color-ink-500:oklch(55% .032 248);--color-ink-600:oklch(46% .034 248);--color-ink-700:oklch(38.5% .035 250);--color-ink-900:oklch(24% .033 254);--color-action-green-50:oklch(97.6% .02 154);--color-action-green-100:oklch(94.5% .042 154);--color-action-green-300:oklch(82.8% .111 154);--color-action-green-500:oklch(68.5% .169 154);--color-action-green-600:oklch(58.8% .158 154);--color-action-green-700:oklch(50% .134 154);--color-action-red-50:oklch(97.6% .02 28);--color-action-red-100:oklch(94.5% .042 28);--color-action-red-300:oklch(82.8% .111 28);--color-action-red-500:oklch(68.5% .169 28);--color-action-red-600:oklch(58.8% .158 28);--color-action-red-700:oklch(50% .134 28)}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}
+52 -5
internal/templates/recipe.html
··· 9 9 <!-- Open Graph meta tags for social sharing (WhatsApp, Facebook, etc.) --> 10 10 <meta property="og:title" content="{{.Recipe.Title}}" /> 11 11 <meta property="og:description" content="{{.Recipe.Description}}" /> 12 - <meta property="og:image" content="{{PublicOrigin}}/favicon.ico" /> 12 + <meta property="og:image" content="{{if .RecipeImage.HasImage}}{{PublicOrigin}}/recipe/{{.RecipeImage.Hash}}/image{{else}}{{PublicOrigin}}/favicon.ico{{end}}" /> 13 13 <meta property="og:type" content="website" /> 14 14 <meta property="og:site_name" content="Careme" /> 15 15 ··· 17 17 <meta name="twitter:card" content="summary" /> 18 18 <meta name="twitter:title" content="{{.Recipe.Title}}" /> 19 19 <meta name="twitter:description" content="{{.Recipe.Description}}" /> 20 - <meta name="twitter:image" content="{{PublicOrigin}}/favicon.ico" /> 20 + <meta name="twitter:image" content="{{if .RecipeImage.HasImage}}{{PublicOrigin}}/recipe/{{.RecipeImage.Hash}}/image{{else}}{{PublicOrigin}}/favicon.ico{{end}}" /> 21 21 {{end}} 22 22 23 23 {{template "tailwind_head" .Style}} ··· 38 38 Location: <span class="font-semibold text-brand-700">{{.Location.Name}}</span> 39 39 {{if .Location.Address}}<span class="text-sm text-gray-500">({{.Location.Address}})</span>{{end}} 40 40 </p> 41 + {{end}} 42 + {{if .OriginHash}} 43 + <a href="/recipes?h={{.OriginHash}}" class="mt-2 inline-flex text-xs font-semibold text-brand-600 hover:text-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2">Back to shopping list</a> 41 44 {{end}} 42 45 </div> 43 46 ··· 46 49 <header class="space-y-2"> 47 50 <h2 class="text-3xl font-semibold text-brand-700">{{.Recipe.Title}}</h2> 48 51 <p class="text-sm text-gray-500">{{.Recipe.Description}}</p> 49 - {{if .OriginHash}} 50 - <a href="/recipes?h={{.OriginHash}}" class="text-xs font-semibold text-brand-600 hover:text-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2">Back to full list</a> 51 - {{end}} 52 + {{template "recipe_image_action" .}} 52 53 </header> 54 + 55 + {{template "recipe_image_panel" .}} 53 56 54 57 <div class="grid gap-6 md:grid-cols-2"> 55 58 <div> ··· 306 309 {{end}} 307 310 </div> 308 311 {{end}} 312 + 313 + {{define "recipe_image_panel"}} 314 + <div id="recipe-image-panel" {{if .RecipeImage.OutOfBand}}hx-swap-oob="outerHTML" {{end}}class="{{if not .RecipeImage.HasImage}}hidden {{end}}overflow-hidden rounded-2xl border border-brand-100 bg-brand-50/60 shadow-sm"> 315 + {{if .RecipeImage.HasImage}} 316 + <img src="/recipe/{{.RecipeImage.Hash}}/image" 317 + alt="Generated image of the dish" 318 + class="h-auto w-full object-cover" 319 + loading="lazy" /> 320 + {{end}} 321 + </div> 322 + {{end}} 323 + 324 + {{define "recipe_image_action"}} 325 + <div id="recipe-image-action"> 326 + {{if .RecipeImage.HasImage}} 327 + {{else if .ServerSignedIn}} 328 + <button type="button" 329 + id="recipe-image-generate-button" 330 + hx-post="/recipe/{{.RecipeHash}}/image" 331 + hx-target="#recipe-image-action" 332 + hx-swap="outerHTML" 333 + hx-disabled-elt="#recipe-image-generate-button" 334 + class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2.5 text-sm font-semibold text-brand-700 shadow-sm transition hover:border-brand-400 hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:text-brand-400"> 335 + <span class="htmx-indicator mr-2" aria-hidden="true"> 336 + <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"> 337 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 338 + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path> 339 + </svg> 340 + </span> 341 + <span>See plated dish</span> 342 + </button> 343 + {{else}} 344 + <a href="{{SignInPath (print "/recipe/" .RecipeHash)}}" 345 + class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2.5 text-sm font-semibold text-brand-700 shadow-sm transition hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 346 + Sign in to see plated dish 347 + </a> 348 + {{end}} 349 + </div> 350 + {{end}} 351 + 352 + {{define "recipe_image_action_response"}} 353 + {{template "recipe_image_action" .}} 354 + {{template "recipe_image_panel" .}} 355 + {{end}}