ai cooking
0
fork

Configure Feed

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

Unauthenticated locations (#404)

* seems to be working

* move validation to config so we can fail faster and have invariants in clerk

* okay b64 everwhere

* fumpt

* fix circular logic beween clerk and domain in config

* no default prod origin

* stop trimming everywhere trust config

* shoppinglsit hash errrors still overly complicated

* simplify wine crap

* fumpt

* simplify

* a little simpler

* don't differntiate that much in locaitons ui

* simple changes before big one

* okay thats better

* okay home is looking better

* tailwind update

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
1508795b 6f01e0e6

+621 -161
+1 -1
cmd/careme/web.go
··· 70 70 locationServer := locations.NewServer(locationStorage, centroids, userStorage) 71 71 locationServer.Register(appRoutes, authClient) 72 72 73 - sitemapHandler := sitemap.New(cache) 73 + sitemapHandler := sitemap.New(cache, cfg.ResolvedPublicOrigin()) 74 74 sitemapHandler.Register(infraRoutes) 75 75 76 76 recipeHandler := recipes.NewHandler(cfg, userStorage, generator, locationStorage, cache, authClient)
+2
deploy/deploy.yaml
··· 46 46 value: "xDzACMu074AcEI_x3dhC" 47 47 - name: ADMIN_EMAILS 48 48 value: "paul.miller@gmail.com" 49 + - name: PUBLIC_ORIGIN 50 + value: "https://careme.cooking" 49 51 - name: APPLICATIONINSIGHTS_CONNECTION_STRING 50 52 value: "InstrumentationKey=a532fcc7-5098-4f44-8dde-ff2f32d6a59b;IngestionEndpoint=https://westus3-1.in.applicationinsights.azure.com/;LiveEndpoint=https://westus3.livediagnostics.monitor.azure.com/;ApplicationId=fdc94780-6135-4a29-980e-ab114a402e58" 51 53
+58 -3
internal/auth/clerk.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 5 6 "errors" 6 7 "fmt" 7 8 "html/template" 8 9 "log/slog" 9 10 "net/http" 11 + "net/url" 10 12 "strings" 11 13 "time" 12 14 ··· 19 21 "github.com/clerk/clerk-sdk-go/v2/jwks" 20 22 "github.com/clerk/clerk-sdk-go/v2/session" 21 23 "github.com/clerk/clerk-sdk-go/v2/user" 24 + "github.com/samber/lo" 22 25 ) 23 26 24 27 var ErrNoSession = errors.New("no valid session found") ··· 163 166 func (c *clerkClient) Register(mux routing.Registrar) { 164 167 mux.HandleFunc("/logout", c.logout) 165 168 mux.HandleFunc("/sign-in", func(w http.ResponseWriter, r *http.Request) { 166 - http.Redirect(w, r, c.cfg.Clerk.Signin(), http.StatusSeeOther) 169 + http.Redirect(w, r, c.signInURL(r, false), http.StatusSeeOther) 167 170 }) 168 171 mux.HandleFunc("/sign-up", func(w http.ResponseWriter, r *http.Request) { 169 - http.Redirect(w, r, c.cfg.Clerk.Signup(), http.StatusSeeOther) 172 + http.Redirect(w, r, c.signInURL(r, true), http.StatusSeeOther) 170 173 }) 171 174 mux.HandleFunc("/auth/establish", func(w http.ResponseWriter, r *http.Request) { 172 175 if c.cfg.Clerk.PublishableKey == "" { ··· 179 182 GoogleTagScript template.HTML 180 183 GoogleConversionTag string 181 184 Signup bool 185 + ReturnTo string // read from a data- attribute in the template to avoid JS-string escaping concerns 182 186 }{ 183 187 PublishableKey: c.cfg.Clerk.PublishableKey, 184 188 GoogleTagScript: templates.GoogleTagScript(), 185 189 GoogleConversionTag: templates.GoogleConversionTag(), 186 - Signup: strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("signup")), "true"), 190 + Signup: strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("signup")), "true"), // used for ad conversions 191 + ReturnTo: returnToFromRequest(r), 187 192 } 188 193 if err := templates.AuthEstablish.Execute(w, data); err != nil { 189 194 slog.ErrorContext(r.Context(), "auth establish template execute error", "error", err) 190 195 http.Error(w, "template error", http.StatusInternalServerError) 191 196 } 192 197 }) 198 + } 199 + 200 + func (c *clerkClient) signInURL(r *http.Request, signup bool) string { 201 + base := c.cfg.Clerk.Signin() 202 + if signup { 203 + base = c.cfg.Clerk.Signup() 204 + } 205 + redirectURL := c.authEstablishURL(signup, returnToFromRequest(r)) 206 + u := lo.Must(url.Parse(base)) 207 + q := u.Query() 208 + q.Set("redirect_url", redirectURL) 209 + u.RawQuery = q.Encode() 210 + return u.String() 211 + } 212 + 213 + func (c *clerkClient) authEstablishURL(signup bool, returnTo string) string { 214 + origin := c.cfg.ResolvedPublicOrigin() // can never be emptpy 215 + u := lo.Must(url.Parse(origin + "/auth/establish")) 216 + q := u.Query() 217 + if signup { 218 + q.Set("signup", "true") 219 + } 220 + if returnTo != "" { 221 + // Keep the entire relative return target in one opaque query value so 222 + // nested ?a=1&b=2 segments are not broken apart during Clerk redirects. 223 + q.Set("return_to_b64", base64.RawURLEncoding.EncodeToString([]byte(returnTo))) 224 + } 225 + u.RawQuery = q.Encode() 226 + return u.String() 227 + } 228 + 229 + func returnToFromRequest(r *http.Request) string { 230 + encoded := strings.TrimSpace(r.URL.Query().Get("return_to_b64")) 231 + decoded, err := base64.RawURLEncoding.DecodeString(encoded) 232 + if err != nil { 233 + return "" 234 + } 235 + return sanitizeReturnTo(string(decoded)) 236 + } 237 + 238 + // only allow redirects to relative paths in our app. 239 + func sanitizeReturnTo(raw string) string { 240 + raw = strings.TrimSpace(raw) 241 + if raw == "" || !strings.HasPrefix(raw, "/") { 242 + return "" 243 + } 244 + if strings.HasPrefix(raw, "//") { 245 + return "" 246 + } 247 + return raw 193 248 } 194 249 195 250 // Toss this in if you're confused :)
+53 -30
internal/config/config.go
··· 3 3 import ( 4 4 "fmt" 5 5 "net/http" 6 + "net/url" 6 7 "os" 7 8 "strings" 8 9 ) 9 10 10 11 const additionalStoresEnableEnv = "EXTRA_STORES_ENABLE" 11 12 13 + const ( 14 + defaultLocalOrigin = "http://localhost:8080" 15 + ) 16 + 12 17 type Config struct { 13 - AI AIConfig `json:"ai"` 14 - Kroger KrogerConfig `json:"kroger"` 15 - Walmart WalmartConfig `json:"walmart"` 16 - Aldi AldiConfig `json:"aldi"` 17 - WholeFoods WholeFoodsConfig `json:"wholefoods"` 18 - Albertsons AlbertsonsConfig `json:"albertsons"` 19 - Publix PublixConfig `json:"publix"` 20 - HEB HEBConfig `json:"heb"` 21 - Mocks MockConfig `json:"mocks"` 22 - Clerk ClerkConfig `json:"clerk"` 23 - Admin AdminConfig `json:"admin"` 18 + AI AIConfig `json:"ai"` 19 + Kroger KrogerConfig `json:"kroger"` 20 + Walmart WalmartConfig `json:"walmart"` 21 + Aldi AldiConfig `json:"aldi"` 22 + WholeFoods WholeFoodsConfig `json:"wholefoods"` 23 + Albertsons AlbertsonsConfig `json:"albertsons"` 24 + Publix PublixConfig `json:"publix"` 25 + HEB HEBConfig `json:"heb"` 26 + Mocks MockConfig `json:"mocks"` 27 + Clerk ClerkConfig `json:"clerk"` 28 + Admin AdminConfig `json:"admin"` 29 + PublicOrigin string `json:"public_origin"` 24 30 } 25 31 26 32 type AIConfig struct { ··· 41 47 SecretKey string 42 48 PublishableKey string 43 49 Domain string 44 - Prod bool 45 50 } 46 51 47 52 func (c *ClerkConfig) IsEnabled() bool { ··· 105 110 return c.ConsumerID != "" && c.PrivateKey != "" 106 111 } 107 112 108 - var ( 109 - localhostSigninRedirect = "?redirect_url=http://localhost:8080/auth/establish" 110 - localhostSignupRedirect = "?redirect_url=http://localhost:8080/auth/establish?signup=true" 111 - ) 112 - 113 - // move to auth pacakage? 114 113 func (c *ClerkConfig) Signin() string { 115 - url := fmt.Sprintf("https://%s/sign-in", c.Domain) 116 - if !c.Prod { 117 - url += localhostSigninRedirect 118 - } 119 - return url 114 + return fmt.Sprintf("https://%s/sign-in", c.Domain) 120 115 } 121 116 122 117 func (c *ClerkConfig) Signup() string { 123 - url := fmt.Sprintf("https://%s/sign-up", c.Domain) 124 - if !c.Prod { 125 - url += localhostSignupRedirect 118 + return fmt.Sprintf("https://%s/sign-up", c.Domain) 119 + } 120 + 121 + func (c *Config) ResolvedPublicOrigin() string { 122 + if origin := strings.TrimRight(strings.TrimSpace(c.PublicOrigin), "/"); origin != "" { 123 + return origin 126 124 } 127 - return url 125 + return defaultLocalOrigin 128 126 } 129 127 130 128 func Load() (*Config, error) { ··· 148 146 Admin: AdminConfig{ 149 147 Emails: parseAdminEmails(os.Getenv("ADMIN_EMAILS")), 150 148 }, 149 + PublicOrigin: os.Getenv("PUBLIC_ORIGIN"), 151 150 Aldi: AldiConfig{ 152 151 Enable: envEnabled("ALDI_ENABLE"), 153 152 }, ··· 170 169 BaseURL: os.Getenv("WALMART_BASE_URL"), 171 170 }, 172 171 } 173 - if strings.HasSuffix(config.Clerk.Domain, "careme.cooking") { 174 - config.Clerk.Prod = true 175 - } 176 172 177 173 return config, validate(config) 178 174 } ··· 182 178 } 183 179 184 180 func validate(cfg *Config) error { 181 + if err := validateAbsoluteURL("public origin", cfg.ResolvedPublicOrigin()); err != nil { 182 + return err 183 + } 184 + 185 + if cfg.Clerk.IsEnabled() { 186 + if err := validateAbsoluteURL("clerk sign-in URL", cfg.Clerk.Signin()); err != nil { 187 + return err 188 + } 189 + if err := validateAbsoluteURL("clerk sign-up URL", cfg.Clerk.Signup()); err != nil { 190 + return err 191 + } 192 + } 193 + 185 194 if cfg.Mocks.Enable { 186 195 return nil 187 196 } ··· 194 203 } 195 204 if cfg.AI.APIKey == "" { 196 205 return fmt.Errorf("AI API key must be set") 206 + } 207 + return nil 208 + } 209 + 210 + func validateAbsoluteURL(name, raw string) error { 211 + parsed, err := url.Parse(strings.TrimSpace(raw)) 212 + if err != nil { 213 + return fmt.Errorf("%s is invalid: %w", name, err) 214 + } 215 + if parsed == nil || parsed.Scheme == "" || parsed.Host == "" { 216 + return fmt.Errorf("%s must be an absolute URL", name) 217 + } 218 + if parsed.Scheme != "http" && parsed.Scheme != "https" { 219 + return fmt.Errorf("%s must use http or https", name) 197 220 } 198 221 return nil 199 222 }
+59 -1
internal/config/config_test.go
··· 1 1 package config 2 2 3 - import "testing" 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 4 7 5 8 func TestLoadEnablesAdditionalStoresFromSharedEnv(t *testing.T) { 6 9 resetStoreEnvs(t) ··· 56 59 } 57 60 } 58 61 62 + func TestLoadUsesConfiguredPublicOrigin(t *testing.T) { 63 + resetStoreEnvs(t) 64 + t.Setenv("ENABLE_MOCKS", "1") 65 + t.Setenv("PUBLIC_ORIGIN", "https://staging.careme.test/") 66 + 67 + cfg, err := Load() 68 + if err != nil { 69 + t.Fatalf("Load() error = %v", err) 70 + } 71 + 72 + if got, want := cfg.ResolvedPublicOrigin(), "https://staging.careme.test"; got != want { 73 + t.Fatalf("expected resolved public origin %q, got %q", want, got) 74 + } 75 + } 76 + 77 + func TestResolvedPublicOriginDefaultsToLocalhostOutsideProd(t *testing.T) { 78 + cfg := &Config{} 79 + if got, want := cfg.ResolvedPublicOrigin(), "http://localhost:8080"; got != want { 80 + t.Fatalf("expected default local origin %q, got %q", want, got) 81 + } 82 + } 83 + 84 + func TestValidate_RejectsInvalidConfiguredPublicOrigin(t *testing.T) { 85 + cfg := &Config{ 86 + Mocks: MockConfig{Enable: true}, 87 + PublicOrigin: "://bad-origin", 88 + } 89 + 90 + err := validate(cfg) 91 + if err == nil || !contains(err.Error(), "public origin") { 92 + t.Fatalf("expected public origin validation error, got %v", err) 93 + } 94 + } 95 + 96 + func TestValidate_RejectsInvalidDerivedClerkURLs(t *testing.T) { 97 + cfg := &Config{ 98 + Mocks: MockConfig{Enable: true}, 99 + Clerk: ClerkConfig{ 100 + SecretKey: "sk_test", 101 + PublishableKey: "pk_test", 102 + Domain: "bad host with spaces", 103 + }, 104 + } 105 + 106 + err := validate(cfg) 107 + if err == nil || !contains(err.Error(), "clerk sign-in URL") { 108 + t.Fatalf("expected clerk sign-in validation error, got %v", err) 109 + } 110 + } 111 + 59 112 func resetStoreEnvs(t *testing.T) { 60 113 t.Helper() 61 114 62 115 for _, name := range []string{ 63 116 "ENABLE_MOCKS", 117 + "PUBLIC_ORIGIN", 64 118 additionalStoresEnableEnv, 65 119 "ALDI_ENABLE", 66 120 "WHOLEFOODS_ENABLE", ··· 71 125 t.Setenv(name, "") 72 126 } 73 127 } 128 + 129 + func contains(got, want string) bool { 130 + return strings.Contains(got, want) 131 + }
+14 -12
internal/mail/mail.go
··· 45 45 } 46 46 47 47 type mailer struct { 48 - cache cache.Cache 49 - userStorage *users.Storage 50 - generator *recipes.Generator // interface requires making params public 51 - locServer locServer 52 - client emailClient 48 + cache cache.Cache 49 + userStorage *users.Storage 50 + generator *recipes.Generator // interface requires making params public 51 + locServer locServer 52 + client emailClient 53 + publicOrigin string 53 54 } 54 55 55 56 // TODO share some of this with web.go? good for mocking? ··· 80 81 } 81 82 82 83 return &mailer{ 83 - cache: cache, 84 - userStorage: userStorage, 85 - generator: generator.(*recipes.Generator), // TODO do better 86 - locServer: locationserver, 87 - client: sendgrid.NewSendClient(sendgridkey), 84 + cache: cache, 85 + userStorage: userStorage, 86 + generator: generator.(*recipes.Generator), // TODO do better 87 + locServer: locationserver, 88 + client: sendgrid.NewSendClient(sendgridkey), 89 + publicOrigin: cfg.ResolvedPublicOrigin(), 88 90 }, nil 89 91 } 90 92 ··· 191 193 } 192 194 193 195 var buf bytes.Buffer 194 - if err := recipes.FormatMail(p, *shoppingList, &buf); err != nil { 196 + if err := recipes.FormatMail(p, *shoppingList, m.publicOrigin, &buf); err != nil { 195 197 slog.ErrorContext(ctx, "failed to format mail", "error", err) 196 198 return 197 199 } ··· 199 201 from := mail.NewEmail("Chef", "chef@careme.cooking") 200 202 subject := "Your new recipes are ready!" 201 203 202 - plainTextContent := "Check out your new recipes at https://careme.cooking/recipes?h=" + paramsHash 204 + plainTextContent := "Check out your new recipes at " + m.publicOrigin + "/recipes?h=" + paramsHash 203 205 204 206 to := mail.NewEmail(user.Email[0], user.Email[0]) 205 207 message := mail.NewSingleEmail(from, subject, to, plainTextContent, buf.String())
+37
internal/recipes/buttons_test.go
··· 137 137 t.Error("HTML should not render finalize helper text when button is enabled") 138 138 } 139 139 } 140 + 141 + func TestFormatShoppingListHTML_SignedOutShowsReadOnlyActions(t *testing.T) { 142 + list := ai.ShoppingList{ 143 + Recipes: []ai.Recipe{ 144 + { 145 + Title: "Shared Recipe", 146 + Description: "Read-only recipe", 147 + Ingredients: []ai.Ingredient{{Name: "ingredient1", Quantity: "1 cup", Price: "2.00"}}, 148 + Instructions: []string{"Step 1"}, 149 + Health: "Healthy", 150 + DrinkPairing: "Water", 151 + }, 152 + }, 153 + } 154 + 155 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 156 + p := DefaultParams(&loc, time.Now()) 157 + w := httptest.NewRecorder() 158 + FormatShoppingListHTML(t.Context(), p, list, false, w) 159 + html := w.Body.String() 160 + 161 + if strings.Contains(html, `type="radio"`) { 162 + t.Error("HTML should not contain save/dismiss radio inputs when signed out") 163 + } 164 + if strings.Contains(html, `Try again, chef`) { 165 + t.Error("HTML should not contain regenerate action text when signed out") 166 + } 167 + if strings.Contains(html, `Assemble Shopping List`) { 168 + t.Error("HTML should not contain finalize action text when signed out") 169 + } 170 + if strings.Contains(html, `Save`) { 171 + t.Error("HTML should not contain save action text when signed out") 172 + } 173 + if strings.Contains(html, `Dismiss`) { 174 + t.Error("HTML should not contain dismiss action text when signed out") 175 + } 176 + }
+18 -9
internal/recipes/html.go
··· 23 23 // should not own. 24 24 type shoppingRecipeView struct { 25 25 ai.Recipe 26 - Hash string 26 + // Hash identifies the individual recipe card and backs recipe-scoped 27 + // links and HTMX endpoints like /recipe/{hash}/save or /recipe/{hash}/wine. 28 + Hash string 29 + // ShoppingListHash identifies the surrounding /recipes?h=... page and is 30 + // used anywhere the card needs to refer back to the full list state. 31 + ShoppingListHash string 32 + ServerSignedIn bool 27 33 DisplayIngredients []ai.Ingredient // merged food and wine 28 34 Dismissed bool // saved already in recipe 29 35 Wine shoppingRecipeWineView ··· 64 70 recipeViews = append(recipeViews, shoppingRecipeView{ 65 71 Recipe: recipe, 66 72 Hash: recipeHash, 73 + ShoppingListHash: hash, 74 + ServerSignedIn: signedIn, 67 75 DisplayIngredients: displayIngredients, 68 76 Dismissed: dismissedHashes[recipeHash], 69 77 Wine: shoppingRecipeWineView{ ··· 190 198 } 191 199 } 192 200 193 - // FormatShoppingRecipeWineHTML renders the shopping list wine recommendation fragment for HTMX swaps. 201 + // FormatShoppingRecipeWineHTML renders the signed-in shopping list wine fragment for HTMX swaps. 194 202 func FormatShoppingRecipeWineHTML(recipeHash, slot string, selection *ai.WineSelection, writer http.ResponseWriter) { 195 203 wineActionID, wineButtonID := shoppingWineDOMIDs(recipeHash) 196 204 winePreviewID := shoppingWinePreviewDOMID(recipeHash) 197 205 wineDetailID, wineDetailButtonID := shoppingWineDetailDOMIDs(recipeHash) 198 206 data := struct { 199 - Hash string 200 - Wine shoppingRecipeWineView 207 + // Hash is used for recipe-scoped DOM IDs and /recipe/{hash}/wine endpoints. 208 + Hash string 209 + ServerSignedIn bool 210 + Wine shoppingRecipeWineView 201 211 }{ 202 - Hash: recipeHash, 212 + Hash: recipeHash, 213 + ServerSignedIn: true, 203 214 Wine: shoppingRecipeWineView{ 204 215 ActionID: wineActionID, 205 216 ActionButtonID: wineButtonID, ··· 234 245 } 235 246 236 247 // drops clarity, instructions and most of shoppinglist 237 - func FormatMail(p *generatorParams, l ai.ShoppingList, writer io.Writer) error { 238 - // TODO just put params into shopping list and pass that up? 239 - 248 + func FormatMail(p *generatorParams, l ai.ShoppingList, publicOrigin string, writer io.Writer) error { 240 249 data := struct { 241 250 Location locations.Location 242 251 Date string ··· 249 258 Date: p.Date.Format("2006-01-02"), 250 259 Hash: p.Hash(), 251 260 Recipes: l.Recipes, 252 - Domain: "https://careme.cooking", 261 + Domain: publicOrigin, 253 262 Style: seasons.GetCurrentStyle(), 254 263 } 255 264
+62 -7
internal/recipes/html_test.go
··· 76 76 if !strings.Contains(html, `/static/htmx@2.0.8.js`) { 77 77 t.Error("shopping list HTML should include htmx script") 78 78 } 79 - if !strings.Contains(html, "shopping-wine-refresh:") { 80 - t.Error("shopping list HTML should include wine refresh history handling") 81 - } 82 79 if !strings.Contains(html, "Shopping list") { 83 80 t.Error("shopping list HTML should render the shopping list section for a single recipe") 84 81 } ··· 276 273 if strings.Contains(html, `name="question"`) { 277 274 t.Error("recipe HTML should not contain question input when signed out") 278 275 } 279 - if !strings.Contains(html, "Sign in to ask follow-up questions.") { 276 + if !strings.Contains(html, "Sign in to ask follow-up questions") { 280 277 t.Error("recipe HTML should prompt signed-out users to sign in for questions") 281 278 } 279 + if strings.Contains(html, `hx-post="/recipe/`) && strings.Contains(html, `/wine"`) { 280 + 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 + } 285 + if strings.Contains(html, `name="feedback"`) { 286 + t.Error("recipe HTML should not contain feedback form when signed out") 287 + } 288 + } 289 + 290 + func TestFormatShoppingListHTML_HidesMutationsWhenSignedOut(t *testing.T) { 291 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 292 + p := DefaultParams(&loc, time.Now()) 293 + multiRecipeList := ai.ShoppingList{ 294 + Recipes: []ai.Recipe{ 295 + { 296 + Title: "Recipe One", 297 + Description: "First recipe", 298 + Ingredients: []ai.Ingredient{{Name: "ingredient1", Quantity: "1 cup", Price: "2.00"}}, 299 + Instructions: []string{ 300 + "Step 1", 301 + }, 302 + Health: "Healthy", 303 + DrinkPairing: "Water", 304 + }, 305 + }, 306 + } 307 + 308 + w := httptest.NewRecorder() 309 + FormatShoppingListHTML(t.Context(), p, multiRecipeList, false, w) 310 + html := w.Body.String() 311 + 312 + isValidHTML(t, html) 313 + if strings.Contains(html, `/recipes/`) && strings.Contains(html, `/regenerate"`) { 314 + t.Error("shopping list HTML should not expose regenerate endpoint when signed out") 315 + } 316 + if strings.Contains(html, `/recipe/`) && strings.Contains(html, `/save"`) { 317 + t.Error("shopping list HTML should not expose save endpoint when signed out") 318 + } 319 + if strings.Contains(html, `/recipe/`) && strings.Contains(html, `/dismiss"`) { 320 + t.Error("shopping list HTML should not expose dismiss endpoint when signed out") 321 + } 322 + if strings.Contains(html, `/recipes/`) && strings.Contains(html, `/finalize"`) { 323 + t.Error("shopping list HTML should not expose finalize endpoint when signed out") 324 + } 325 + if strings.Contains(html, `/recipe/`) && strings.Contains(html, `/wine?view=shopping`) { 326 + t.Error("shopping list HTML should not expose shopping wine endpoint when signed out") 327 + } 328 + if strings.Contains(html, "Try again, chef") { 329 + t.Error("shopping list HTML should hide regenerate action when signed out") 330 + } 331 + if strings.Contains(html, "Assemble Shopping List") { 332 + t.Error("shopping list HTML should hide finalize action when signed out") 333 + } 334 + if strings.Contains(html, `id="save-`) { 335 + t.Error("shopping list HTML should hide save controls when signed out") 336 + } 337 + if strings.Contains(html, `id="dismiss-`) { 338 + t.Error("shopping list HTML should hide dismiss controls when signed out") 339 + } 282 340 } 283 341 284 342 func TestFormatRecipeHTML_RendersCachedWineRecommendation(t *testing.T) { ··· 413 471 } 414 472 if !strings.Contains(body, `hx-post="/recipe/recipe-hash/wine?view=shopping&slot=action"`) { 415 473 t.Fatalf("expected shopping wine endpoint in response, got body: %s", body) 416 - } 417 - if !strings.Contains(body, `sessionStorage.setItem('shopping-wine-refresh:`) { 418 - t.Fatalf("expected shopping wine picker to mark the page for refresh after browser back, got body: %s", body) 419 474 } 420 475 } 421 476
+51 -17
internal/recipes/server.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/base64" 6 7 "errors" 7 8 "fmt" 8 9 "html/template" ··· 32 33 33 34 func setTextContent(w http.ResponseWriter) { 34 35 w.Header().Set("Content-Type", "text/html; charset=utf-8") 36 + } 37 + 38 + func requestURIOrPath(r *http.Request) string { 39 + if r == nil { 40 + return "/" 41 + } 42 + if uri := strings.TrimSpace(r.URL.RequestURI()); uri != "" { 43 + return uri 44 + } 45 + if path := strings.TrimSpace(r.URL.Path); path != "" { 46 + return path 47 + } 48 + return "/" 49 + } 50 + 51 + func signInPath(returnTo string) string { 52 + returnTo = strings.TrimSpace(returnTo) 53 + if returnTo == "" { 54 + return "/sign-in" 55 + } 56 + // We base64-url encode the full relative target so nested query strings survive 57 + // Clerk's redirect_url handoff without splitting into separate top-level params. 58 + return "/sign-in?return_to_b64=" + url.QueryEscape(base64.RawURLEncoding.EncodeToString([]byte(returnTo))) 59 + } 60 + 61 + func redirectToSignIn(w http.ResponseWriter, r *http.Request, status int) { 62 + target := signInPath(requestURIOrPath(r)) 63 + if isHTMXRequest(r) { 64 + w.Header().Set("HX-Redirect", target) 65 + } 66 + http.Error(w, "must be logged in", status) 35 67 } 36 68 37 69 type locServer interface { ··· 188 220 } 189 221 _, err := s.clerk.GetUserIDFromRequest(r) 190 222 if errors.Is(err, auth.ErrNoSession) { 191 - w.Header().Set("HX-Redirect", "/sign-in") 192 - http.Error(w, "must be logged in to ask a question", http.StatusUnauthorized) 223 + redirectToSignIn(w, r, http.StatusUnauthorized) 193 224 return 194 225 } 195 226 ··· 258 289 hash := strings.TrimSpace(r.PathValue("hash")) 259 290 if hash == "" { 260 291 http.Error(w, "missing recipe hash", http.StatusBadRequest) 292 + return 293 + } 294 + if _, err := s.clerk.GetUserIDFromRequest(r); err != nil { 295 + redirectToSignIn(w, r, http.StatusUnauthorized) 261 296 return 262 297 } 263 298 if selection, err := s.WineFromCache(ctx, hash); err == nil { ··· 331 366 hash := r.PathValue("hash") 332 367 if hash == "" { 333 368 http.Error(w, "missing recipe hash", http.StatusBadRequest) 369 + return 370 + } 371 + if _, err := s.clerk.GetUserIDFromRequest(r); errors.Is(err, auth.ErrNoSession) { 372 + redirectToSignIn(w, r, http.StatusUnauthorized) 334 373 return 335 374 } 336 375 if err := r.ParseForm(); err != nil { ··· 412 451 currentUser, err := s.storage.FromRequest(ctx, r, s.clerk) 413 452 if err != nil { 414 453 if errors.Is(err, auth.ErrNoSession) { 415 - w.Header().Set("HX-Redirect", "/sign-in") 416 - http.Error(w, "must be logged in to save recipes", http.StatusUnauthorized) 454 + redirectToSignIn(w, r, http.StatusUnauthorized) 417 455 return 418 456 } 419 457 slog.ErrorContext(ctx, "failed to load user for recipe save", "error", err) ··· 503 541 currentUser, err := s.storage.FromRequest(ctx, r, s.clerk) 504 542 if err != nil { 505 543 if errors.Is(err, auth.ErrNoSession) { 506 - w.Header().Set("HX-Redirect", "/sign-in") 507 - http.Error(w, "must be logged in to dismiss recipes", http.StatusUnauthorized) 544 + redirectToSignIn(w, r, http.StatusUnauthorized) 508 545 return 509 546 } 510 547 slog.ErrorContext(ctx, "failed to load user for recipe dismiss", "error", err) ··· 578 615 currentUser, err := s.storage.FromRequest(ctx, r, s.clerk) 579 616 if err != nil { 580 617 if errors.Is(err, auth.ErrNoSession) { 581 - if isHTMXRequest(r) { 582 - w.Header().Set("HX-Redirect", "/sign-in") 583 - } 584 - http.Error(w, "must be logged in to regenerate recipes", http.StatusUnauthorized) 618 + redirectToSignIn(w, r, http.StatusUnauthorized) 585 619 return 586 620 } 587 621 http.Error(w, "unable to load account", http.StatusInternalServerError) ··· 624 658 userid, err := s.clerk.GetUserIDFromRequest(r) 625 659 if err != nil { 626 660 if errors.Is(err, auth.ErrNoSession) { 627 - if isHTMXRequest(r) { 628 - w.Header().Set("HX-Redirect", "/sign-in") 629 - } 630 - http.Error(w, "must be logged in to finalize recipes", http.StatusUnauthorized) 661 + redirectToSignIn(w, r, http.StatusUnauthorized) 631 662 return 632 663 } 633 664 http.Error(w, "unable to load account", http.StatusInternalServerError) ··· 771 802 return 772 803 } 773 804 if r.URL.Query().Get("mail") == "true" { 774 - if err := FormatMail(p, *slist, w); err != nil { 805 + if err := FormatMail(p, *slist, s.cfg.ResolvedPublicOrigin(), w); err != nil { 775 806 slog.ErrorContext(ctx, "failed to render mail template", "error", err) 776 807 http.Error(w, "failed to render mail template", http.StatusInternalServerError) 777 808 } ··· 829 860 http.Error(w, "unable to load account", http.StatusInternalServerError) 830 861 return 831 862 } 832 - slog.InfoContext(ctx, "failed got no sesion from request", "error", err, "url", r.URL.String()) 833 - http.Redirect(w, r, "/", http.StatusSeeOther) 863 + if _, cacheErr := s.FromCache(ctx, p.Hash()); cacheErr == nil { 864 + redirectToHash(w, r, p.Hash(), false /*useStart*/) 865 + return 866 + } 867 + http.Redirect(w, r, signInPath(requestURIOrPath(r)), http.StatusSeeOther) 834 868 return 835 869 } 836 870
+125 -6
internal/recipes/server_test.go
··· 196 196 } 197 197 } 198 198 199 + func TestHandleRecipes_GuestRedirectsToSignInWhenCacheMisses(t *testing.T) { 200 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 201 + s := &server{ 202 + recipeio: IO(cacheStore), 203 + storage: users.NewStorage(cacheStore), 204 + clerk: noSessionAuth{}, 205 + locServer: staticLocationLookup{location: &locations.Location{ 206 + ID: "70001001", 207 + Name: "Test Store", 208 + ZipCode: "94105", 209 + }}, 210 + } 211 + 212 + req := httptest.NewRequest(http.MethodGet, "/recipes?location=70001001&instructions=make+it+vegetarian", nil) 213 + rr := httptest.NewRecorder() 214 + 215 + s.handleRecipes(rr, req) 216 + 217 + if rr.Code != http.StatusSeeOther { 218 + t.Fatalf("expected status %d, got %d", http.StatusSeeOther, rr.Code) 219 + } 220 + if got, want := rr.Header().Get("Location"), signInPath("/recipes?location=70001001&instructions=make+it+vegetarian"); got != want { 221 + t.Fatalf("expected redirect location %q, got %q", want, got) 222 + } 223 + } 224 + 225 + func TestHandleRecipes_GuestRedirectsToCachedHashWhenCacheHits(t *testing.T) { 226 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 227 + s := &server{ 228 + recipeio: IO(cacheStore), 229 + storage: users.NewStorage(cacheStore), 230 + clerk: noSessionAuth{}, 231 + locServer: staticLocationLookup{location: &locations.Location{ 232 + ID: "70001001", 233 + Name: "Test Store", 234 + ZipCode: "94105", 235 + }}, 236 + } 237 + 238 + p := DefaultParams(&locations.Location{ID: "70001001", Name: "Test Store", ZipCode: "94105"}, time.Date(2026, 3, 6, 0, 0, 0, 0, time.FixedZone("PST", -8*60*60))) 239 + p.Instructions = "make it vegetarian" 240 + hash := p.Hash() 241 + if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 242 + Recipes: []ai.Recipe{{Title: "Cached Recipe", Description: "Already made"}}, 243 + }, hash); err != nil { 244 + t.Fatalf("failed to seed shopping list: %v", err) 245 + } 246 + 247 + req := httptest.NewRequest(http.MethodGet, "/recipes?location=70001001&date=2026-03-06&instructions=make+it+vegetarian", nil) 248 + rr := httptest.NewRecorder() 249 + 250 + s.handleRecipes(rr, req) 251 + 252 + if rr.Code != http.StatusSeeOther { 253 + t.Fatalf("expected status %d, got %d", http.StatusSeeOther, rr.Code) 254 + } 255 + location := rr.Header().Get("Location") 256 + u, err := url.Parse(location) 257 + if err != nil { 258 + t.Fatalf("failed to parse redirect location %q: %v", location, err) 259 + } 260 + if got := u.Query().Get("h"); got != hash { 261 + t.Fatalf("expected redirect hash %q, got %q", hash, got) 262 + } 263 + if u.Query().Has("start") { 264 + t.Fatalf("expected guest cache hit redirect without start param, got %q", location) 265 + } 266 + } 267 + 199 268 func TestHandleRecipes_SameRequestDifferentDirectivesProduceDifferentHashes(t *testing.T) { 200 269 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 201 270 storage := users.NewStorage(cacheStore) ··· 705 774 if rr.Code != http.StatusUnauthorized { 706 775 t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) 707 776 } 708 - if got := rr.Header().Get("HX-Redirect"); got != "/sign-in" { 709 - t.Fatalf("expected HX-Redirect to /, got %q", got) 777 + if got, want := rr.Header().Get("HX-Redirect"), signInPath("/recipe/hash/question"); got != want { 778 + t.Fatalf("expected HX-Redirect %q, got %q", want, got) 710 779 } 711 780 } 712 781 ··· 757 826 758 827 if rr.Code != http.StatusBadRequest { 759 828 t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code) 829 + } 830 + } 831 + 832 + func TestHandleWine_NoSessionHTMXSetsRedirectHeader(t *testing.T) { 833 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 834 + s := &server{ 835 + recipeio: IO(cacheStore), 836 + storage: users.NewStorage(cacheStore), 837 + clerk: noSessionAuth{}, 838 + } 839 + 840 + req := httptest.NewRequest(http.MethodPost, "/recipe/hash/wine?view=shopping", nil) 841 + req.Header.Set("HX-Request", "true") 842 + req.SetPathValue("hash", "hash") 843 + rr := httptest.NewRecorder() 844 + 845 + s.handleWine(rr, req) 846 + 847 + if rr.Code != http.StatusUnauthorized { 848 + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) 849 + } 850 + if got, want := rr.Header().Get("HX-Redirect"), signInPath("/recipe/hash/wine?view=shopping"); got != want { 851 + t.Fatalf("expected HX-Redirect %q, got %q", want, got) 760 852 } 761 853 } 762 854 ··· 1033 1125 if rr.Code != http.StatusUnauthorized { 1034 1126 t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) 1035 1127 } 1036 - if got := rr.Header().Get("HX-Redirect"); got != "/sign-in" { 1037 - t.Fatalf("expected HX-Redirect to /sign-in, got %q", got) 1128 + if got, want := rr.Header().Get("HX-Redirect"), signInPath("/recipe/hash/save"); got != want { 1129 + t.Fatalf("expected HX-Redirect %q, got %q", want, got) 1038 1130 } 1039 1131 } 1040 1132 ··· 1206 1298 if rr.Code != http.StatusUnauthorized { 1207 1299 t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) 1208 1300 } 1209 - if got := rr.Header().Get("HX-Redirect"); got != "/sign-in" { 1210 - t.Fatalf("expected HX-Redirect to /sign-in, got %q", got) 1301 + if got, want := rr.Header().Get("HX-Redirect"), signInPath("/recipe/hash/dismiss"); got != want { 1302 + t.Fatalf("expected HX-Redirect %q, got %q", want, got) 1211 1303 } 1212 1304 } 1213 1305 ··· 1664 1756 } 1665 1757 if feedback.Comment != "Great flavor and easy cleanup." { 1666 1758 t.Fatalf("unexpected comment: %q", feedback.Comment) 1759 + } 1760 + } 1761 + 1762 + func TestHandleFeedback_NoSessionHTMXSetsRedirectHeader(t *testing.T) { 1763 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1764 + s := &server{ 1765 + recipeio: IO(cacheStore), 1766 + storage: users.NewStorage(cacheStore), 1767 + clerk: noSessionAuth{}, 1768 + } 1769 + 1770 + form := url.Values{ 1771 + "cooked": {"true"}, 1772 + } 1773 + req := httptest.NewRequest(http.MethodPost, "/recipe/hash/feedback", strings.NewReader(form.Encode())) 1774 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 1775 + req.Header.Set("HX-Request", "true") 1776 + req.SetPathValue("hash", "hash") 1777 + rr := httptest.NewRecorder() 1778 + 1779 + s.handleFeedback(rr, req) 1780 + 1781 + if rr.Code != http.StatusUnauthorized { 1782 + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) 1783 + } 1784 + if got, want := rr.Header().Get("HX-Redirect"), signInPath("/recipe/hash/feedback"); got != want { 1785 + t.Fatalf("expected HX-Redirect %q, got %q", want, got) 1667 1786 } 1668 1787 } 1669 1788
+10 -7
internal/sitemap/sitemap.go
··· 13 13 ) 14 14 15 15 type Server struct { 16 - cache cache.ListCache 16 + cache cache.ListCache 17 + publicOrigin string 17 18 } 18 19 19 20 const ( 20 - domain = "https://careme.cooking" 21 21 robots = `# Allow all search engines to crawl the site 22 22 User-agent: * 23 23 Allow: / ··· 27 27 ` 28 28 ) 29 29 30 - func New(c cache.ListCache) *Server { 31 - return &Server{cache: c} 30 + func New(c cache.ListCache, publicOrigin string) *Server { 31 + return &Server{ 32 + cache: c, 33 + publicOrigin: publicOrigin, 34 + } 32 35 } 33 36 34 37 func (s *Server) Register(mux routing.Registrar) { ··· 55 58 return 56 59 } 57 60 entries := make([]urlEntry, 0, len(hashes)+1) 58 - entries = append(entries, urlEntry{Loc: domain + "/about"}) 61 + entries = append(entries, urlEntry{Loc: s.publicOrigin + "/about"}) 59 62 60 63 // this is going to get too big. at some point we need a real db to find latest 61 64 // or we track new entries and expire a lsit. 62 65 for _, key := range hashes { 63 66 hash := strings.TrimPrefix(key, recipes.ShoppingListCachePrefix) 64 - entries = append(entries, urlEntry{Loc: domain + "/recipes?h=" + hash}) 67 + entries = append(entries, urlEntry{Loc: s.publicOrigin + "/recipes?h=" + hash}) 65 68 } 66 69 slog.InfoContext(r.Context(), "serving sitemap with recipe urls", "count", len(entries), "blobcount", len(hashes)) 67 70 ··· 80 83 81 84 func (s *Server) handleRobots(w http.ResponseWriter, r *http.Request) { 82 85 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 83 - full := fmt.Sprintf(robots, domain) 86 + full := fmt.Sprintf(robots, s.publicOrigin) 84 87 if _, err := w.Write([]byte(full)); err != nil { 85 88 slog.ErrorContext(r.Context(), "failed to write robots.txt", "error", err) 86 89 }
+15 -13
internal/sitemap/sitemap_test.go
··· 15 15 "careme/internal/recipes" 16 16 ) 17 17 18 + const testPublicOrigin = "https://example.careme.test" 19 + 18 20 func TestHandleSitemapReturnsXMLWithCachedRecipeHashes(t *testing.T) { 19 21 t.Chdir(t.TempDir()) 20 22 ··· 36 38 hashes = append(hashes, hash) 37 39 } 38 40 39 - server := New(cacheStore) 41 + server := New(cacheStore, testPublicOrigin) 40 42 rr := httptest.NewRecorder() 41 43 req := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil) 42 44 server.handleSitemap(rr, req) ··· 58 60 t.Fatalf("expected %d sitemap urls, got %d", expectedCount, len(parsed.URLs)) 59 61 } 60 62 61 - if !containsSitemapURL(parsed.URLs, "https://careme.cooking/about") { 62 - t.Fatalf("missing expected static URL %q in sitemap body: %s", "https://careme.cooking/about", rr.Body.String()) 63 + if !containsSitemapURL(parsed.URLs, testPublicOrigin+"/about") { 64 + t.Fatalf("missing expected static URL %q in sitemap body: %s", testPublicOrigin+"/about", rr.Body.String()) 63 65 } 64 66 65 67 for _, hash := range hashes { 66 - wantURL := "https://careme.cooking/recipes?h=" + hash 68 + wantURL := testPublicOrigin + "/recipes?h=" + hash 67 69 if !containsSitemapURL(parsed.URLs, wantURL) { 68 70 t.Fatalf("missing expected URL %q in sitemap body: %s", wantURL, rr.Body.String()) 69 71 } ··· 81 83 t.Fatalf("failed to save prefixed key: %v", err) 82 84 } 83 85 84 - server := New(cacheStore) 86 + server := New(cacheStore, testPublicOrigin) 85 87 rr := httptest.NewRecorder() 86 88 req := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil) 87 89 server.handleSitemap(rr, req) ··· 97 99 if len(parsed.URLs) != 2 { 98 100 t.Fatalf("expected two URLs (about + recipe), got %d", len(parsed.URLs)) 99 101 } 100 - if !containsSitemapURL(parsed.URLs, "https://careme.cooking/about") { 101 - t.Fatalf("missing expected static URL %q in sitemap body: %s", "https://careme.cooking/about", rr.Body.String()) 102 + if !containsSitemapURL(parsed.URLs, testPublicOrigin+"/about") { 103 + t.Fatalf("missing expected static URL %q in sitemap body: %s", testPublicOrigin+"/about", rr.Body.String()) 102 104 } 103 - wantURL := "https://careme.cooking/recipes?h=" + hash 105 + wantURL := testPublicOrigin + "/recipes?h=" + hash 104 106 if !containsSitemapURL(parsed.URLs, wantURL) { 105 107 t.Fatalf("missing expected URL %q in sitemap body: %s", wantURL, rr.Body.String()) 106 108 } ··· 117 119 t.Fatalf("failed to save legacy root key: %v", err) 118 120 } 119 121 120 - server := New(cacheStore) 122 + server := New(cacheStore, testPublicOrigin) 121 123 rr := httptest.NewRecorder() 122 124 req := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil) 123 125 server.handleSitemap(rr, req) ··· 133 135 if len(parsed.URLs) != 1 { 134 136 t.Fatalf("expected one URL (about) with no shoppinglist keys, got %d", len(parsed.URLs)) 135 137 } 136 - if parsed.URLs[0].Loc != "https://careme.cooking/about" { 137 - t.Fatalf("expected only URL %q, got %q", "https://careme.cooking/about", parsed.URLs[0].Loc) 138 + if parsed.URLs[0].Loc != testPublicOrigin+"/about" { 139 + t.Fatalf("expected only URL %q, got %q", testPublicOrigin+"/about", parsed.URLs[0].Loc) 138 140 } 139 141 } 140 142 141 143 func TestHandleRobotsReturnsExpectedContent(t *testing.T) { 142 - server := &Server{} 144 + server := New(nil, testPublicOrigin) 143 145 rr := httptest.NewRecorder() 144 146 req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil) 145 147 ··· 151 153 if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "text/plain") { 152 154 t.Fatalf("expected text content type, got %q", got) 153 155 } 154 - if rr.Body.String() != fmt.Sprintf(robots, domain) { 156 + if rr.Body.String() != fmt.Sprintf(robots, testPublicOrigin) { 155 157 t.Fatalf("unexpected robots.txt body:\n%s", rr.Body.String()) 156 158 } 157 159 }
+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-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-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)}.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)}}
+6 -5
internal/templates/auth_establish.html
··· 6 6 <title>Signing In</title> 7 7 {{.GoogleTagScript}} 8 8 </head> 9 - <body> 9 + <body data-return-to="{{.ReturnTo}}"> 10 10 <script 11 11 crossorigin="anonymous" 12 12 data-clerk-publishable-key="{{.PublishableKey}}" ··· 18 18 url.searchParams.delete("__clerk_db_jwt"); 19 19 history.replaceState({}, "", url.toString()); 20 20 21 - const redirectHome = () => location.replace("/"); 21 + const returnTo = document.body.dataset.returnTo || "/"; 22 + const finishRedirect = () => location.replace(returnTo); 22 23 23 24 if ({{.Signup}} && 24 25 "{{.GoogleConversionTag}}" !== "" && 25 26 typeof gtag === "function") { 26 27 gtag("event", "conversion", { 27 28 send_to: "{{.GoogleConversionTag}}", 28 - event_callback: redirectHome, 29 + event_callback: finishRedirect, 29 30 }); 30 - setTimeout(redirectHome, 1500); 31 + setTimeout(finishRedirect, 1500); 31 32 return; 32 33 } 33 34 34 - redirectHome(); 35 + finishRedirect(); 35 36 }); 36 37 </script> 37 38 </body>
+45 -21
internal/templates/home.html
··· 106 106 </div> 107 107 </div> 108 108 {{else}} 109 - <p class="whitespace-pre-line text-ink-700">Careme will: 110 - 111 - Find your local grocery stores. 112 - Check the store's inventory for fresh meat and seasonal produce. 113 - Generate a weekly recipe plan from a variety of cuisines and cooking styles. 114 - </p> 115 - <!-- Signed-out state --> 116 - <div class="mt-6"> 117 - <p class="mb-3 block text-sm font-medium text-ink-700"> 118 - Sign in or create an account to continue: 119 - </p> 120 - <div class="flex flex-col gap-3 sm:flex-row"> 121 - <a href="/sign-in" 122 - class="inline-flex items-center justify-center rounded-lg bg-brand-600 px-4 py-2.5 font-semibold text-white shadow-md transition hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 123 - Sign In 124 - </a> 125 - <a href="/sign-up" 126 - class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2.5 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"> 127 - Sign Up 128 - </a> 109 + <div class="mx-auto max-w-xl space-y-6"> 110 + <div class="friendly-card-soft border border-brand-100 bg-brand-50/40 p-5"> 111 + <h2 class="text-lg font-semibold text-brand-700">Careme will:</h2> 112 + <ul class="mt-2 list-disc space-y-1 pl-5 text-sm text-ink-700"> 113 + <li>Check your local store's inventory for fresh meat and seasonal produce.</li> 114 + <li>Generate a weekly recipe plan from a variety of cuisines and cooking styles.</li> 115 + </ul> 129 116 <a href="/about" 130 - class="inline-flex items-center justify-center rounded-lg border-2 border-brand-300 bg-white/90 px-4 py-2.5 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"> 117 + class="mt-4 inline-flex w-full items-center justify-center rounded-lg border-2 border-brand-300 bg-white/90 px-4 py-2.5 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"> 131 118 About Careme 132 119 </a> 120 + </div> 121 + 122 + <div class="friendly-card-soft border border-brand-100 bg-brand-50/40 p-5"> 123 + <h2 class="text-lg font-semibold text-brand-700">Find a store</h2> 124 + <form method="GET" action="/locations" class="mt-4 space-y-2"> 125 + <label for="zip" class="block text-sm font-medium text-ink-700"> 126 + Enter ZIP code: 127 + </label> 128 + <div class="flex flex-col gap-3"> 129 + <input id="zip" name="zip" type="text" inputmode="numeric" pattern="\d{5}" required 130 + placeholder="e.g. 90210" 131 + class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-ink-900 placeholder-gray-400 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400" /> 132 + <button type="submit" 133 + class="inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2.5 font-medium text-white shadow-md transition hover:bg-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 134 + Find stores 135 + </button> 136 + <button type="button" id="use-location-btn" 137 + class="inline-flex w-full items-center justify-center rounded-lg border border-brand-300 bg-white px-3 py-2 text-sm font-medium text-brand-700 shadow-sm transition hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 138 + Use your location 139 + </button> 140 + <p id="use-location-message" class="hidden text-xs text-ink-600" aria-live="polite"></p> 141 + </div> 142 + </form> 143 + </div> 144 + 145 + <div class="friendly-card-soft border border-brand-100 bg-brand-50/40 p-5"> 146 + <p class="mb-3 block text-sm font-medium text-ink-700">Sign in or create an account before building recipes:</p> 147 + <div class="flex flex-col gap-3"> 148 + <a href="/sign-in" 149 + class="inline-flex items-center justify-center rounded-lg bg-brand-600 px-4 py-2.5 font-semibold text-white shadow-md transition hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 150 + Sign In 151 + </a> 152 + <a href="/sign-up" 153 + class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2.5 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"> 154 + Sign Up 155 + </a> 156 + </div> 133 157 </div> 134 158 </div> 135 159 {{end}}
+5
internal/templates/locations.html
··· 17 17 <div class="border-b border-brand-100 p-8"> 18 18 <h1 class="text-4xl font-extrabold tracking-tight text-brand-700">Nearby Stores</h1> 19 19 <p class="mt-2 text-gray-600">Showing results near ZIP <span class="font-semibold text-brand-700">{{.Zip}}</span>.</p> 20 + {{if not .ServerSignedIn}} 21 + <p class="mt-2 text-sm text-gray-500">You can browse stores now, but building recipes requires signing in.</p> 22 + {{end}} 20 23 </div> 21 24 22 25 <ul id="locations-list" class="divide-y divide-brand-100"> ··· 40 43 41 44 <div class="flex flex-wrap items-center gap-2 sm:justify-end"> 42 45 {{if .SupportsStaples}} 46 + {{if $.ServerSignedIn}} 43 47 <form 44 48 method="POST" 45 49 action="/user/favorite" ··· 54 58 </span> 55 59 </button> 56 60 </form> 61 + {{end}} 57 62 <a href="/recipes?location={{.ID}}" 58 63 class="rounded-lg border border-brand-300 bg-white px-3 py-2 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"> 59 64 Recipes!
+13 -3
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="https://careme.cooking/favicon.ico" /> 12 + <meta property="og:image" content="{{PublicOrigin}}/favicon.ico" /> 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="https://careme.cooking/favicon.ico" /> 20 + <meta name="twitter:image" content="{{PublicOrigin}}/favicon.ico" /> 21 21 {{end}} 22 22 23 23 {{template "tailwind_head" .Style}} ··· 146 146 </button> 147 147 </div> 148 148 {{else}} 149 - <p class="text-sm text-gray-500">Sign in to ask follow-up questions.</p> 149 + <a href="{{SignInPath (print "/recipe/" .RecipeHash)}}" 150 + class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2 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"> 151 + Sign in to ask follow-up questions 152 + </a> 150 153 {{end}} 151 154 152 155 {{if .ServerSignedIn}} ··· 282 285 {{end}} 283 286 </div> 284 287 {{else}} 288 + {{if .ServerSignedIn}} 285 289 <button type="button" 286 290 id="wine-picker-button" 287 291 hx-post="/recipe/{{.RecipeHash}}/wine" ··· 293 297 class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2 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"> 294 298 🍷 Choose a wine 295 299 </button> 300 + {{else}} 301 + <a href="{{SignInPath (print "/recipe/" .RecipeHash)}}" 302 + class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2 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"> 303 + Sign in for wine picks 304 + </a> 305 + {{end}} 296 306 {{end}} 297 307 </div> 298 308 {{end}}
+32 -25
internal/templates/shoppinglist.html
··· 9 9 <!-- Open Graph meta tags for social sharing (WhatsApp, Facebook, etc.) --> 10 10 <meta property="og:title" content="{{(index .Recipes 0).Title}}" /> 11 11 <meta property="og:description" content="{{(index .Recipes 0).Description}}" /> 12 - <meta property="og:image" content="https://careme.cooking/favicon.ico" /> 12 + <meta property="og:image" content="{{PublicOrigin}}/favicon.ico" /> 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="{{(index .Recipes 0).Title}}" /> 19 19 <meta name="twitter:description" content="{{(index .Recipes 0).Description}}" /> 20 - <meta name="twitter:image" content="https://careme.cooking/favicon.ico" /> 20 + <meta name="twitter:image" content="{{PublicOrigin}}/favicon.ico" /> 21 21 {{end}} 22 22 23 23 {{template "tailwind_head" .Style}} ··· 40 40 </div> 41 41 42 42 <div class="p-8"> 43 + {{if .ServerSignedIn}} 43 44 <form id="regenerateForm" 44 45 method="POST" 45 46 action="/recipes/{{.Hash}}/regenerate" 46 47 hx-post="/recipes/{{.Hash}}/regenerate" 47 48 hx-params="instructions" 48 49 hx-swap="none"> 49 - 50 50 <div class="sticky top-3 z-20 -mx-2 px-2 pb-4"> 51 51 <div class="friendly-card-soft flex flex-col gap-3 border border-brand-100 bg-brand-50/80 p-4 shadow-sm backdrop-blur-[2px] sm:flex-row sm:items-center"> 52 52 <label for="instructions" class="text-sm font-medium text-ink-700 sm:w-40">Chef notes</label> ··· 63 63 </div> 64 64 </div> 65 65 </form> 66 + {{else if .Instructions}} 67 + <div class="sticky top-3 z-20 -mx-2 px-2 pb-4"> 68 + <div class="friendly-card-soft flex flex-col gap-3 border border-brand-100 bg-brand-50/80 p-4 shadow-sm backdrop-blur-[2px] sm:flex-row sm:items-center"> 69 + <label for="instructions" class="text-sm font-medium text-ink-700 sm:w-40">Chef notes</label> 70 + <input id="instructions" 71 + type="text" 72 + name="instructions" 73 + value="{{.Instructions}}" 74 + readonly 75 + class="w-full flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-ink-700 shadow-sm" /> 76 + </div> 77 + </div> 78 + {{end}} 66 79 67 80 <div class="mt-8 space-y-8"> 68 81 {{range .Recipes}} ··· 75 88 <p class="mt-1 text-sm text-ink-500">{{.Description}}</p> 76 89 </div> 77 90 <div class="flex flex-wrap items-center gap-3"> 91 + {{if .ServerSignedIn}} 78 92 <input type="radio" 79 93 {{if .Saved}}checked{{end}} 80 94 name="recipe-{{.Hash}}" ··· 107 121 class="inline-flex cursor-pointer items-center justify-center rounded-lg border-2 border-action-red-500 bg-action-red-50 px-4 py-2 text-sm font-medium text-action-red-700 transition hover:bg-action-red-100 peer-checked/dismiss:border-action-red-700 peer-checked/dismiss:bg-action-red-600 peer-checked/dismiss:text-white"> 108 122 Dismiss 109 123 </label> 124 + {{end}} 110 125 <button type="button" 111 126 onclick="var d=this.closest('article').querySelector('details'); if (d) d.open=!d.open;" 112 127 class="inline-flex items-center justify-center rounded-lg border-2 border-brand-400 bg-brand-50 px-4 py-2 text-sm font-medium text-brand-700 transition hover:bg-brand-100 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 113 128 Details 114 129 </button> 115 130 {{template "shopping_recipe_wine_action" .}} 116 - <span class="profile-status text-xs font-medium text-ink-600" aria-live="polite"></span> 131 + {{if .ServerSignedIn}}<span class="profile-status text-xs font-medium text-ink-600" aria-live="polite"></span>{{end}} 117 132 </div> 118 133 </header> 119 134 ··· 192 207 </section> 193 208 </main> 194 209 <script src="/static/htmx@2.0.8.js"></script> 195 - <script> 196 - (() => { 197 - // After an HTMX wine pick, browser Back/Forward can restore a stale shopping-list 198 - // DOM from history instead of refetching the page. We mark the page as dirty in 199 - // sessionStorage and force one reload on pageshow so the restored page matches the 200 - // latest server-rendered wine state, then clear the flag immediately. 201 - const key = "shopping-wine-refresh:{{.Hash}}"; 202 - window.addEventListener("pageshow", (event) => { 203 - if (sessionStorage.getItem(key) !== "1") return; 204 - if (event.persisted) { 205 - sessionStorage.removeItem(key); 206 - location.reload(); 207 - return; 208 - } 209 - sessionStorage.removeItem(key); 210 - }); 211 - })(); 212 - </script> 213 210 {{template "clerk_refresh.html" .}} 214 211 </body> 215 212 </html> ··· 227 224 {{end}} 228 225 229 226 {{define "shopping_finalize_controls_content"}} 227 + {{if .ServerSignedIn}} 228 + {{template "shopping_finalize_controls_signed_in_content" .}} 229 + {{end}} 230 + {{end}} 231 + 232 + {{define "shopping_finalize_controls_signed_in_content"}} 230 233 <span class="inline-flex" {{if not .HasSavedRecipes}}title="Save at least one recipe to assemble your shopping list."{{end}}> 231 234 <button type="button" 232 235 id="finalizeButton" ··· 246 249 {{end}} 247 250 248 251 {{define "shopping_finalize_controls_response"}} 249 - {{template "shopping_finalize_controls_oob" .}} 252 + <div id="shopping-finalize-controls" hx-swap-oob="outerHTML" class="mt-10 flex flex-wrap items-center gap-4"> 253 + {{template "shopping_finalize_controls_signed_in_content" .}} 254 + </div> 250 255 {{end}} 251 256 252 257 {{define "shopping_recipe_wine_action"}} ··· 263 268 264 269 {{define "shopping_recipe_wine_action_content"}} 265 270 {{if not .Wine.Recommendation}} 271 + {{if .ServerSignedIn}} 266 272 <button type="button" 267 273 id="{{.Wine.ActionButtonID}}" 268 274 hx-post="/recipe/{{.Hash}}/wine?view=shopping&slot=action" ··· 271 277 hx-disabled-elt="#{{.Wine.ActionButtonID}}" 272 278 hx-on::before-request="this.textContent='🍷🔄';" 273 279 hx-on::response-error="this.textContent='😞';" 274 - hx-on::after-request="if(event.detail.successful){sessionStorage.setItem('shopping-wine-refresh:{{$.Hash}}','1');}" 275 280 aria-label="Choose wine" 276 281 title="Choose wine" 277 282 class="inline-flex items-center justify-center rounded-lg border-2 border-brand-300 bg-white px-4 py-2 text-sm font-medium 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"> 278 283 🍷 279 284 </button> 285 + {{end}} 280 286 {{end}} 281 287 {{end}} 282 288 ··· 346 352 {{end}} 347 353 </div> 348 354 {{else}} 355 + {{if .ServerSignedIn}} 349 356 <button type="button" 350 357 id="{{.Wine.DetailButtonID}}" 351 358 hx-post="/recipe/{{.Hash}}/wine?view=shopping&slot=details" ··· 354 361 hx-disabled-elt="#{{.Wine.DetailButtonID}}" 355 362 hx-on::before-request="this.textContent='Choosing 🍷 ...';" 356 363 hx-on::response-error="this.textContent='wine failure 😞';" 357 - hx-on::after-request="if(event.detail.successful){sessionStorage.setItem('shopping-wine-refresh:{{$.Hash}}','1');}" 358 364 class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2 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"> 359 365 🍷 Choose a wine 360 366 </button> 367 + {{end}} 361 368 {{end}} 362 369 {{end}} 363 370
+14
internal/templates/templates.go
··· 3 3 import ( 4 4 "context" 5 5 "embed" 6 + "encoding/base64" 6 7 "html/template" 8 + "net/url" 7 9 "os" 10 + "strings" 8 11 9 12 "careme/internal/config" 10 13 "careme/internal/logsetup" ··· 27 30 funcs := template.FuncMap{ 28 31 "ClerkEnabled": func() bool { return config.Clerk.PublishableKey != "" }, 29 32 "ClerkPublishableKey": func() string { return config.Clerk.PublishableKey }, 33 + "PublicOrigin": func() string { return config.ResolvedPublicOrigin() }, 34 + "SignInPath": signInPath, 30 35 "TailwindAssetPath": func() string { return tailwindAssetPath }, 31 36 } 32 37 tmpls, err := template.New("all").Funcs(funcs).ParseFS(htmlFiles, "*.html") ··· 56 61 panic("template " + name + " not found") 57 62 } 58 63 return tmpl 64 + } 65 + 66 + func signInPath(returnTo string) string { 67 + returnTo = strings.TrimSpace(returnTo) 68 + if returnTo == "" { 69 + return "/sign-in" 70 + } 71 + encoded := base64.RawURLEncoding.EncodeToString([]byte(returnTo)) 72 + return "/sign-in?return_to_b64=" + url.QueryEscape(encoded) 59 73 } 60 74 61 75 var (