@ssejal8/recipe-ui-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.npmr +1 -0
  3. package/README.md +354 -0
  4. package/apps/recipe-web/package.json +31 -0
  5. package/apps/recipe-web/src/app.css +418 -0
  6. package/apps/recipe-web/src/app.d.ts +32 -0
  7. package/apps/recipe-web/src/app.html +25 -0
  8. package/apps/recipe-web/src/lib/api/mealdb.ts +240 -0
  9. package/apps/recipe-web/src/lib/components/AppHeader.svelte +290 -0
  10. package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +46 -0
  11. package/apps/recipe-web/src/lib/components/Modal.svelte +33 -0
  12. package/apps/recipe-web/src/lib/components/RecipeCard.svelte +100 -0
  13. package/apps/recipe-web/src/lib/components/RecipeForm.svelte +560 -0
  14. package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +77 -0
  15. package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +185 -0
  16. package/apps/recipe-web/src/lib/components/Toaster.svelte +111 -0
  17. package/apps/recipe-web/src/lib/domain/recipe.ts +130 -0
  18. package/apps/recipe-web/src/lib/domain/validation.ts +189 -0
  19. package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +145 -0
  20. package/apps/recipe-web/src/lib/stencil/register.ts +28 -0
  21. package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +81 -0
  22. package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +98 -0
  23. package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +73 -0
  24. package/apps/recipe-web/src/lib/stores/planner.svelte.ts +275 -0
  25. package/apps/recipe-web/src/lib/stores/theme.svelte.ts +67 -0
  26. package/apps/recipe-web/src/lib/stores/toast.svelte.ts +66 -0
  27. package/apps/recipe-web/src/routes/+error.svelte +36 -0
  28. package/apps/recipe-web/src/routes/+layout.svelte +62 -0
  29. package/apps/recipe-web/src/routes/+page.svelte +352 -0
  30. package/apps/recipe-web/src/routes/+page.ts +107 -0
  31. package/apps/recipe-web/src/routes/favorites/+page.svelte +170 -0
  32. package/apps/recipe-web/src/routes/my-recipes/+layout.ts +55 -0
  33. package/apps/recipe-web/src/routes/my-recipes/+page.svelte +180 -0
  34. package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +104 -0
  35. package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +67 -0
  36. package/apps/recipe-web/src/routes/planner/+page.svelte +422 -0
  37. package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +538 -0
  38. package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +24 -0
  39. package/apps/recipe-web/static/favicon.svg +8 -0
  40. package/apps/recipe-web/static/robots.txt +2 -0
  41. package/apps/recipe-web/svelte.config.js +35 -0
  42. package/apps/recipe-web/tsconfig.json +14 -0
  43. package/apps/recipe-web/vite.config.ts +23 -0
  44. package/package.json +29 -0
  45. package/packages/recipe-kit-stencil/LICENSE +21 -0
  46. package/packages/recipe-kit-stencil/README.md +185 -0
  47. package/packages/recipe-kit-stencil/package-lock.json +4772 -0
  48. package/packages/recipe-kit-stencil/package.json +91 -0
  49. package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +38 -0
  50. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.css +102 -0
  51. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +31 -0
  52. package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +52 -0
  53. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.css +155 -0
  54. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +105 -0
  55. package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +44 -0
  56. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.css +64 -0
  57. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +50 -0
  58. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +58 -0
  59. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.css +225 -0
  60. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +182 -0
  61. package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +57 -0
  62. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.css +163 -0
  63. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +176 -0
  64. package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +42 -0
  65. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.css +71 -0
  66. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +114 -0
  67. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +66 -0
  68. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.css +280 -0
  69. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +179 -0
  70. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +70 -0
  71. package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +67 -0
  72. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.css +141 -0
  73. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +152 -0
  74. package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +93 -0
  75. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +41 -0
  76. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.css +91 -0
  77. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +115 -0
  78. package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +47 -0
  79. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.css +146 -0
  80. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +181 -0
  81. package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +76 -0
  82. package/packages/recipe-kit-stencil/src/components.d.ts +1192 -0
  83. package/packages/recipe-kit-stencil/src/global/rk-global.css +104 -0
  84. package/packages/recipe-kit-stencil/src/index.html +259 -0
  85. package/packages/recipe-kit-stencil/src/index.ts +6 -0
  86. package/packages/recipe-kit-stencil/stencil.config.ts +41 -0
  87. package/packages/recipe-kit-stencil/tsconfig.json +24 -0
@@ -0,0 +1,538 @@
1
+ <script lang="ts">
2
+ import { goto } from '$app/navigation';
3
+ import ConfirmDialog from '$components/ConfirmDialog.svelte';
4
+ import { toSummary, type Recipe } from '$domain/recipe';
5
+ import WebComponent from '$lib/stencil/WebComponent.svelte';
6
+ import { favorites } from '$stores/favorites.svelte';
7
+ import { myRecipes } from '$stores/myRecipes.svelte';
8
+ import { DAYS, MEALS, planner, type DayKey, type MealKey } from '$stores/planner.svelte';
9
+ import { toasts } from '$stores/toast.svelte';
10
+ import type { PageData } from './$types';
11
+
12
+ let { data }: { data: PageData } = $props();
13
+
14
+ /**
15
+ * API recipes arrive from `load`; user recipes are read from the store. Both
16
+ * resolve to the same `Recipe` shape, so the template below never branches on
17
+ * where the data came from — only on whether the user owns it.
18
+ */
19
+ const recipe = $derived<Recipe | null>(data.isCustom ? (myRecipes.get(data.id) ?? null) : data.recipe);
20
+
21
+ const isOwned = $derived(recipe?.origin === 'custom');
22
+ const isFavorite = $derived(recipe ? favorites.has(recipe.id) : false);
23
+
24
+ // Ingredient checklist state — deliberately not persisted; it is a cooking aid
25
+ // for the current session, not part of the recipe.
26
+ let checked = $state<Set<string>>(new Set());
27
+ $effect(() => {
28
+ void recipe?.id;
29
+ checked = new Set();
30
+ });
31
+
32
+ function toggleIngredient(key: string) {
33
+ const next = new Set(checked);
34
+ if (next.has(key)) next.delete(key);
35
+ else next.add(key);
36
+ checked = next;
37
+ }
38
+
39
+ function toggleFavorite() {
40
+ if (!recipe) return;
41
+ const nowFavorite = favorites.toggle(toSummary(recipe));
42
+ toasts.push(nowFavorite ? `Saved “${recipe.name}” to favorites.` : `Removed “${recipe.name}” from favorites.`, {
43
+ tone: nowFavorite ? 'success' : 'info'
44
+ });
45
+ }
46
+
47
+ /* ---------------------------------------------------------- add to planner */
48
+
49
+ let planOpen = $state(false);
50
+ let planDay = $state<DayKey>('mon');
51
+ let planMeal = $state<MealKey>('dinner');
52
+
53
+ function confirmPlan() {
54
+ if (!recipe) return;
55
+ planner.assign(planDay, planMeal, toSummary(recipe));
56
+ planOpen = false;
57
+
58
+ const dayLabel = DAYS.find((day) => day.key === planDay)?.label ?? planDay;
59
+ const mealLabel = MEALS.find((meal) => meal.key === planMeal)?.label ?? planMeal;
60
+ toasts.success(`Added to ${dayLabel} ${mealLabel.toLowerCase()}.`, {
61
+ label: 'View plan',
62
+ run: () => void goto('/planner')
63
+ });
64
+ }
65
+
66
+ /* -------------------------------------------------------------- delete own */
67
+
68
+ let confirmDelete = $state(false);
69
+
70
+ function handleDelete() {
71
+ if (!recipe) return;
72
+ const removed = myRecipes.remove(recipe.id);
73
+ confirmDelete = false;
74
+ if (!removed) return;
75
+
76
+ toasts.push(`Deleted “${removed.name}”.`, {
77
+ tone: 'info',
78
+ action: { label: 'Undo', run: () => myRecipes.restore(removed) }
79
+ });
80
+ void goto('/my-recipes');
81
+ }
82
+
83
+ const totalTime = $derived(recipe?.minutes && recipe.minutes > 0 ? `${recipe.minutes} min` : null);
84
+ const progress = $derived(recipe && recipe.ingredients.length > 0 ? Math.round((checked.size / recipe.ingredients.length) * 100) : 0);
85
+ </script>
86
+
87
+ <svelte:head>
88
+ <title>{recipe ? `${recipe.name} — Mise` : 'Recipe — Mise'}</title>
89
+ {#if recipe}
90
+ <meta name="description" content={`${recipe.name} — ${recipe.category} recipe from ${recipe.area} cuisine, with ${recipe.ingredients.length} ingredients.`} />
91
+ {/if}
92
+ </svelte:head>
93
+
94
+ {#if data.loadError}
95
+ <rk-empty-state tone="error" icon="⚠️" heading="Could not load this recipe" message={data.loadError}>
96
+ <button class="btn btn-primary" type="button" onclick={() => location.reload()}>Try again</button>
97
+ </rk-empty-state>
98
+ {:else if !recipe}
99
+ <!--
100
+ Reached when a custom recipe id is not in the store: either it was deleted,
101
+ or the link was opened in a different browser. During SSR of a custom id
102
+ this also renders briefly, before hydration reads localStorage.
103
+ -->
104
+ <rk-empty-state icon="🔎" heading="Recipe not available" message="This recipe is not in this browser's collection. It may have been deleted.">
105
+ <a class="btn btn-primary" href="/my-recipes">Go to my recipes</a>
106
+ </rk-empty-state>
107
+ {:else}
108
+ <nav class="crumbs small" aria-label="Breadcrumb">
109
+ <a href="/">Discover</a>
110
+ <span aria-hidden="true">/</span>
111
+ <a href={`/?category=${encodeURIComponent(recipe.category)}`}>{recipe.category}</a>
112
+ <span aria-hidden="true">/</span>
113
+ <span class="muted">{recipe.name}</span>
114
+ </nav>
115
+
116
+ <article class="recipe">
117
+ <header class="head">
118
+ <div class="head-text">
119
+ <div class="row tags-row">
120
+ <rk-badge tone="primary" variant="solid">{recipe.category}</rk-badge>
121
+ {#if recipe.area && recipe.area !== 'Unknown'}
122
+ <rk-badge tone="accent">{recipe.area}</rk-badge>
123
+ {/if}
124
+ {#if isOwned}
125
+ <rk-badge tone="success">Your recipe</rk-badge>
126
+ {/if}
127
+ {#each recipe.tags as tag (tag)}
128
+ <rk-badge variant="outline">{tag}</rk-badge>
129
+ {/each}
130
+ </div>
131
+
132
+ <h1 class="page-title">{recipe.name}</h1>
133
+
134
+ <dl class="facts">
135
+ <div>
136
+ <dt>Ingredients</dt>
137
+ <dd>{recipe.ingredients.length}</dd>
138
+ </div>
139
+ <div>
140
+ <dt>Steps</dt>
141
+ <dd>{recipe.steps.length}</dd>
142
+ </div>
143
+ {#if totalTime}
144
+ <div>
145
+ <dt>Total time</dt>
146
+ <dd>{totalTime}</dd>
147
+ </div>
148
+ {/if}
149
+ {#if recipe.servings}
150
+ <div>
151
+ <dt>Serves</dt>
152
+ <dd>{recipe.servings}</dd>
153
+ </div>
154
+ {/if}
155
+ </dl>
156
+
157
+ <div class="row actions">
158
+ <button class="btn" class:btn-primary={!isFavorite} type="button" onclick={toggleFavorite} aria-pressed={isFavorite}>
159
+ <svg viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.9" fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true">
160
+ <path d="M12 20.5 4.6 13.3a4.7 4.7 0 0 1 0-6.7 4.7 4.7 0 0 1 6.7 0l.7.7.7-.7a4.7 4.7 0 0 1 6.7 0 4.7 4.7 0 0 1 0 6.7Z" />
161
+ </svg>
162
+ {isFavorite ? 'Favorited' : 'Add to favorites'}
163
+ </button>
164
+
165
+ <button class="btn btn-accent" type="button" onclick={() => (planOpen = true)}>
166
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
167
+ <rect x="3" y="4.5" width="18" height="16" rx="2.5" />
168
+ <path d="M8 3v3M16 3v3M3 10h18M12 14v4M10 16h4" />
169
+ </svg>
170
+ Add to meal plan
171
+ </button>
172
+
173
+ {#if isOwned}
174
+ <a class="btn" href={`/my-recipes/${recipe.id}/edit`}>Edit</a>
175
+ <button class="btn btn-danger" type="button" onclick={() => (confirmDelete = true)}>Delete</button>
176
+ {/if}
177
+
178
+ {#if recipe.youtubeUrl}
179
+ <a class="btn btn-ghost" href={recipe.youtubeUrl} target="_blank" rel="noreferrer noopener">Watch video ↗</a>
180
+ {/if}
181
+ {#if recipe.sourceUrl}
182
+ <a class="btn btn-ghost" href={recipe.sourceUrl} target="_blank" rel="noreferrer noopener">Original source ↗</a>
183
+ {/if}
184
+ </div>
185
+ </div>
186
+
187
+ <div class="head-media">
188
+ {#if recipe.image}
189
+ <img src={recipe.image} alt={recipe.name} width="640" height="480" />
190
+ {:else}
191
+ <div class="media-placeholder" aria-hidden="true">🍲</div>
192
+ {/if}
193
+ </div>
194
+ </header>
195
+
196
+ <div class="body">
197
+ <section class="ingredients card-surface" aria-labelledby="ingredients-heading">
198
+ <div class="ing-head">
199
+ <h2 class="section-title" id="ingredients-heading">Ingredients</h2>
200
+ {#if checked.size > 0}
201
+ <button class="btn btn-ghost btn-sm" type="button" onclick={() => (checked = new Set())}>Reset</button>
202
+ {/if}
203
+ </div>
204
+
205
+ {#if checked.size > 0}
206
+ <div class="progress" role="progressbar" aria-valuenow={progress} aria-valuemin="0" aria-valuemax="100" aria-label="Ingredients gathered">
207
+ <span style={`width: ${progress}%`}></span>
208
+ </div>
209
+ {/if}
210
+
211
+ <ul class="ing-list">
212
+ {#each recipe.ingredients as ingredient, index (`${ingredient.name}-${index}`)}
213
+ {@const key = `${index}-${ingredient.name}`}
214
+ <li class:is-checked={checked.has(key)}>
215
+ <label>
216
+ <input type="checkbox" checked={checked.has(key)} onchange={() => toggleIngredient(key)} />
217
+ <span class="ing-name">{ingredient.name}</span>
218
+ {#if ingredient.measure}
219
+ <span class="ing-measure">{ingredient.measure}</span>
220
+ {/if}
221
+ </label>
222
+ </li>
223
+ {/each}
224
+ </ul>
225
+
226
+ {#if recipe.notes}
227
+ <div class="notes">
228
+ <h3 class="notes-title">Notes</h3>
229
+ <p class="small">{recipe.notes}</p>
230
+ </div>
231
+ {/if}
232
+ </section>
233
+
234
+ <section class="instructions" aria-labelledby="instructions-heading">
235
+ <h2 class="section-title" id="instructions-heading">Method</h2>
236
+ <ol class="steps">
237
+ {#each recipe.steps as step, index (index)}
238
+ <li>
239
+ <span class="step-index" aria-hidden="true">{index + 1}</span>
240
+ <p>{step}</p>
241
+ </li>
242
+ {/each}
243
+ </ol>
244
+ </section>
245
+ </div>
246
+ </article>
247
+
248
+ <!-- Planner assignment dialog, built on the Stencil modal. -->
249
+ <WebComponent
250
+ tag="rk-modal"
251
+ props={{ open: planOpen, heading: 'Add to meal plan', subheading: recipe.name, size: 'sm' }}
252
+ events={{ rkModalClose: (() => (planOpen = false)) as (event: CustomEvent<never>) => void }}
253
+ >
254
+ {#snippet children()}
255
+ <div class="plan-form field-grid">
256
+ <label class="field">
257
+ <span class="field-label">Day</span>
258
+ <select class="select" bind:value={planDay}>
259
+ {#each DAYS as day (day.key)}
260
+ <option value={day.key}>{day.label}</option>
261
+ {/each}
262
+ </select>
263
+ </label>
264
+
265
+ <label class="field">
266
+ <span class="field-label">Meal</span>
267
+ <select class="select" bind:value={planMeal}>
268
+ {#each MEALS as meal (meal.key)}
269
+ <option value={meal.key}>{meal.label}</option>
270
+ {/each}
271
+ </select>
272
+ </label>
273
+ </div>
274
+
275
+ {#if planner.meal(planDay, planMeal)}
276
+ <p class="small warn">
277
+ {planner.meal(planDay, planMeal)?.name} is already planned for this slot and will be replaced.
278
+ </p>
279
+ {/if}
280
+
281
+ <div slot="footer" class="dialog-actions">
282
+ <button class="btn" type="button" onclick={() => (planOpen = false)}>Cancel</button>
283
+ <button class="btn btn-primary" type="button" onclick={confirmPlan}>Add to plan</button>
284
+ </div>
285
+ {/snippet}
286
+ </WebComponent>
287
+
288
+ <ConfirmDialog
289
+ open={confirmDelete}
290
+ heading="Delete this recipe?"
291
+ message={`“${recipe.name}” will be removed from your collection, your favorites and every meal plan. You can undo this straight after.`}
292
+ confirmLabel="Delete recipe"
293
+ destructive
294
+ onconfirm={handleDelete}
295
+ oncancel={() => (confirmDelete = false)}
296
+ />
297
+ {/if}
298
+
299
+ <style>
300
+ .crumbs {
301
+ display: flex;
302
+ flex-wrap: wrap;
303
+ align-items: center;
304
+ gap: 8px;
305
+ margin-bottom: 20px;
306
+ color: var(--rk-muted);
307
+ }
308
+
309
+ .crumbs a {
310
+ color: var(--rk-ink-2);
311
+ text-decoration: none;
312
+ }
313
+
314
+ .crumbs a:hover {
315
+ color: var(--rk-primary);
316
+ text-decoration: underline;
317
+ }
318
+
319
+ .head {
320
+ display: grid;
321
+ grid-template-columns: 1.05fr 0.95fr;
322
+ gap: clamp(20px, 4vw, 44px);
323
+ align-items: start;
324
+ }
325
+
326
+ .tags-row {
327
+ margin-bottom: 14px;
328
+ }
329
+
330
+ .facts {
331
+ display: flex;
332
+ flex-wrap: wrap;
333
+ gap: 10px 32px;
334
+ margin: 20px 0 24px;
335
+ }
336
+
337
+ .facts div {
338
+ display: grid;
339
+ gap: 2px;
340
+ }
341
+
342
+ .facts dt {
343
+ font-size: 0.72rem;
344
+ text-transform: uppercase;
345
+ letter-spacing: 0.07em;
346
+ color: var(--rk-muted);
347
+ font-weight: 650;
348
+ }
349
+
350
+ .facts dd {
351
+ margin: 0;
352
+ font-size: 1.16rem;
353
+ font-weight: 650;
354
+ font-family: var(--rk-font-display);
355
+ }
356
+
357
+ .actions {
358
+ gap: 8px;
359
+ }
360
+
361
+ .head-media img,
362
+ .media-placeholder {
363
+ width: 100%;
364
+ aspect-ratio: 4 / 3;
365
+ object-fit: cover;
366
+ border-radius: var(--rk-radius-lg);
367
+ box-shadow: var(--rk-shadow);
368
+ }
369
+
370
+ .media-placeholder {
371
+ display: grid;
372
+ place-items: center;
373
+ font-size: 4rem;
374
+ background: var(--rk-surface-2);
375
+ border: 1px solid var(--rk-border);
376
+ }
377
+
378
+ .body {
379
+ display: grid;
380
+ grid-template-columns: minmax(260px, 340px) 1fr;
381
+ gap: clamp(20px, 3vw, 40px);
382
+ margin-top: clamp(32px, 5vw, 56px);
383
+ align-items: start;
384
+ }
385
+
386
+ .ingredients {
387
+ position: sticky;
388
+ top: calc(var(--app-header-h) + 16px);
389
+ }
390
+
391
+ .ing-head {
392
+ display: flex;
393
+ align-items: center;
394
+ justify-content: space-between;
395
+ gap: 10px;
396
+ margin-bottom: 14px;
397
+ }
398
+
399
+ .progress {
400
+ height: 5px;
401
+ border-radius: 999px;
402
+ background: var(--rk-border);
403
+ overflow: hidden;
404
+ margin-bottom: 14px;
405
+ }
406
+
407
+ .progress span {
408
+ display: block;
409
+ height: 100%;
410
+ background: var(--rk-accent);
411
+ transition: width var(--rk-transition);
412
+ }
413
+
414
+ .ing-list {
415
+ list-style: none;
416
+ margin: 0;
417
+ padding: 0;
418
+ display: grid;
419
+ gap: 2px;
420
+ }
421
+
422
+ .ing-list label {
423
+ display: grid;
424
+ grid-template-columns: auto 1fr auto;
425
+ align-items: baseline;
426
+ gap: 10px;
427
+ padding: 8px 6px;
428
+ border-radius: var(--rk-radius-sm);
429
+ cursor: pointer;
430
+ }
431
+
432
+ .ing-list label:hover {
433
+ background: var(--rk-surface-2);
434
+ }
435
+
436
+ .ing-list input {
437
+ accent-color: var(--rk-accent);
438
+ width: 16px;
439
+ height: 16px;
440
+ cursor: pointer;
441
+ }
442
+
443
+ .ing-name {
444
+ font-weight: 550;
445
+ }
446
+
447
+ .ing-measure {
448
+ font-size: 0.85rem;
449
+ color: var(--rk-muted);
450
+ text-align: right;
451
+ font-variant-numeric: tabular-nums;
452
+ }
453
+
454
+ .ing-list li.is-checked .ing-name,
455
+ .ing-list li.is-checked .ing-measure {
456
+ text-decoration: line-through;
457
+ opacity: 0.55;
458
+ }
459
+
460
+ .notes {
461
+ margin-top: 18px;
462
+ padding-top: 16px;
463
+ border-top: 1px solid var(--rk-border);
464
+ }
465
+
466
+ .notes-title {
467
+ font-size: 0.95rem;
468
+ margin-bottom: 6px;
469
+ }
470
+
471
+ .steps {
472
+ list-style: none;
473
+ counter-reset: step;
474
+ margin: 16px 0 0;
475
+ padding: 0;
476
+ display: grid;
477
+ gap: 16px;
478
+ }
479
+
480
+ .steps li {
481
+ display: grid;
482
+ grid-template-columns: 34px 1fr;
483
+ gap: 14px;
484
+ align-items: start;
485
+ }
486
+
487
+ .step-index {
488
+ display: grid;
489
+ place-items: center;
490
+ width: 34px;
491
+ height: 34px;
492
+ border-radius: 50%;
493
+ background: var(--rk-primary-soft);
494
+ color: var(--rk-primary-strong);
495
+ font-weight: 700;
496
+ font-size: 0.88rem;
497
+ font-variant-numeric: tabular-nums;
498
+ }
499
+
500
+ .steps p {
501
+ padding-top: 5px;
502
+ line-height: 1.68;
503
+ }
504
+
505
+ .plan-form {
506
+ margin-bottom: 4px;
507
+ }
508
+
509
+ .warn {
510
+ margin-top: 12px;
511
+ padding: 10px 12px;
512
+ border-radius: var(--rk-radius-sm);
513
+ background: var(--rk-warning-soft);
514
+ color: var(--rk-warning);
515
+ font-weight: 550;
516
+ }
517
+
518
+ .dialog-actions {
519
+ display: flex;
520
+ gap: 10px;
521
+ justify-content: flex-end;
522
+ }
523
+
524
+ @media (max-width: 900px) {
525
+ .head,
526
+ .body {
527
+ grid-template-columns: 1fr;
528
+ }
529
+
530
+ .head-media {
531
+ order: -1;
532
+ }
533
+
534
+ .ingredients {
535
+ position: static;
536
+ }
537
+ }
538
+ </style>
@@ -0,0 +1,24 @@
1
+ import { getRecipe, MealDbError } from '$api/mealdb';
2
+ import { isCustomId, type Recipe } from '$domain/recipe';
3
+ import { error } from '@sveltejs/kit';
4
+ import type { PageLoad } from './$types';
5
+
6
+ export const load: PageLoad = async ({ params, fetch }) => {
7
+ // User-authored recipes live in localStorage, which `load` cannot reach.
8
+ // The page component resolves them from the store after hydration.
9
+ if (isCustomId(params.id)) {
10
+ return { id: params.id, recipe: null as Recipe | null, isCustom: true, loadError: null as string | null };
11
+ }
12
+
13
+ try {
14
+ const recipe = await getRecipe(params.id, fetch);
15
+ if (!recipe) error(404, `No recipe found with id “${params.id}”.`);
16
+ return { id: params.id, recipe, isCustom: false, loadError: null as string | null };
17
+ } catch (cause) {
18
+ // A network problem is not a 404 — keep the page up and offer a retry.
19
+ if (cause instanceof MealDbError) {
20
+ return { id: params.id, recipe: null as Recipe | null, isCustom: false, loadError: cause.message };
21
+ }
22
+ throw cause;
23
+ }
24
+ };
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Mise">
2
+ <rect width="64" height="64" rx="14" fill="#d94f1b" />
3
+ <path
4
+ d="M14 30h36a4 4 0 0 1 4 4 16 16 0 0 1-16 16H26A16 16 0 0 1 10 34a4 4 0 0 1 4-4Z"
5
+ fill="#fffaf6"
6
+ />
7
+ <path d="M22 24c0-4 3-5 3-8s-2-3-2-3M32 24c0-5 4-6 4-9s-2-4-2-4M42 24c0-4 3-5 3-8s-2-3-2-3" stroke="#fffaf6" stroke-width="3" stroke-linecap="round" fill="none" />
8
+ </svg>
@@ -0,0 +1,2 @@
1
+ User-agent: *
2
+ Allow: /
@@ -0,0 +1,35 @@
1
+ import adapterAuto from '@sveltejs/adapter-auto';
2
+ import adapterNode from '@sveltejs/adapter-node';
3
+ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
4
+
5
+ /*
6
+ * adapter-auto detects Vercel / Netlify / Cloudflare at build time and needs no
7
+ * configuration there. It produces nothing runnable locally, though, so setting
8
+ * `ADAPTER=node` switches to adapter-node — used for `npm run preview:node` and
9
+ * for self-hosting behind your own Node process.
10
+ */
11
+ const adapter = process.env.ADAPTER === 'node' ? adapterNode : adapterAuto;
12
+
13
+ /** @type {import('@sveltejs/kit').Config} */
14
+ const config = {
15
+ preprocess: vitePreprocess(),
16
+
17
+ compilerOptions: {
18
+ // Stencil elements are registered at runtime, so the Svelte compiler must
19
+ // not warn about (or try to resolve) the `rk-*` tags it sees in markup.
20
+ customElement: false,
21
+ },
22
+
23
+ kit: {
24
+ adapter: adapter(),
25
+
26
+ alias: {
27
+ $components: 'src/lib/components',
28
+ $stores: 'src/lib/stores',
29
+ $api: 'src/lib/api',
30
+ $domain: 'src/lib/domain',
31
+ },
32
+ },
33
+ };
34
+
35
+ export default config;
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./.svelte-kit/tsconfig.json",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "checkJs": true,
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "resolveJsonModule": true,
9
+ "skipLibCheck": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "moduleResolution": "bundler"
13
+ }
14
+ }
@@ -0,0 +1,23 @@
1
+ import { sveltekit } from '@sveltejs/kit/vite';
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig({
5
+ plugins: [sveltekit()],
6
+
7
+ optimizeDeps: {
8
+ // The Stencil loader is a browser-only ESM entry point; letting Vite
9
+ // pre-bundle it keeps the dev server from re-resolving it on every reload.
10
+ include: ['recipe-kit-stencil/loader'],
11
+ },
12
+
13
+ ssr: {
14
+ // Never pull the custom-element registry into the server bundle.
15
+ noExternal: [],
16
+ external: ['recipe-kit-stencil/loader'],
17
+ },
18
+
19
+ server: {
20
+ port: 5173,
21
+ strictPort: false,
22
+ },
23
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@ssejal8/recipe-ui-kit",
3
+ "publishConfig": {
4
+ "registry": "https://registry.npmjs.org/",
5
+ "access": "public"
6
+ },
7
+ "version": "0.1.0",
8
+ "description": "Recipe Finder & Meal Planner — SvelteKit application plus a published StencilJS component library.",
9
+ "type": "module",
10
+ "workspaces": [
11
+ "packages/*",
12
+ "apps/*"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "dev": "npm run build --workspace recipe-kit-stencil && npm run dev --workspace recipe-web",
19
+ "dev:app": "npm run dev --workspace recipe-web",
20
+ "dev:kit": "npm run start --workspace recipe-kit-stencil",
21
+ "build": "npm run build --workspace recipe-kit-stencil && npm run build --workspace recipe-web",
22
+ "build:kit": "npm run build --workspace recipe-kit-stencil",
23
+ "build:app": "npm run build --workspace recipe-web",
24
+ "preview": "npm run preview --workspace recipe-web",
25
+ "check": "npm run check --workspace recipe-web",
26
+ "test": "npm run test --workspace recipe-kit-stencil",
27
+ "publish:kit": "npm publish --workspace recipe-kit-stencil"
28
+ }
29
+ }