@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,560 @@
1
+ <!--
2
+ Create / edit form for user-authored recipes.
3
+
4
+ Validation runs on submit and then live on every change, so the first
5
+ interaction is not pre-emptively covered in red messages. `rk-tag-input`
6
+ handles the tag field and reports changes through `rkTagsChange`.
7
+ -->
8
+ <script lang="ts">
9
+ import type { Ingredient, Recipe } from '$domain/recipe';
10
+ import { joinSteps } from '$domain/recipe';
11
+ import { countErrors, emptyDraft, isValid, LIMITS, parseOptionalCount, validateDraft, type RecipeDraft, type RecipeErrors } from '$domain/validation';
12
+ import WebComponent from '$lib/stencil/WebComponent.svelte';
13
+ import { untrack } from 'svelte';
14
+
15
+ interface Props {
16
+ /** Categories offered in the select. */
17
+ categories: string[];
18
+ /** Cuisines offered in the select. */
19
+ areas: string[];
20
+ /** Existing recipe when editing; omitted when creating. */
21
+ recipe?: Recipe;
22
+ submitLabel?: string;
23
+ /** Returns an error message to display, or nothing on success. */
24
+ onsubmit: (payload: Omit<Recipe, 'id' | 'origin' | 'createdAt' | 'updatedAt'>) => string | void;
25
+ oncancel: () => void;
26
+ }
27
+
28
+ let { categories, areas, recipe, submitLabel = 'Save recipe', onsubmit, oncancel }: Props = $props();
29
+
30
+ function draftFrom(source?: Recipe): RecipeDraft {
31
+ if (!source) return emptyDraft();
32
+ return {
33
+ name: source.name,
34
+ category: source.category,
35
+ area: source.area,
36
+ image: source.image ?? '',
37
+ minutes: source.minutes ? String(source.minutes) : '',
38
+ servings: source.servings ? String(source.servings) : '',
39
+ ingredients: source.ingredients.length > 0 ? source.ingredients.map((row) => ({ ...row })) : emptyDraft().ingredients,
40
+ steps: source.steps.length > 0 ? [...source.steps] : [''],
41
+ tags: [...source.tags],
42
+ notes: source.notes ?? ''
43
+ };
44
+ }
45
+
46
+ // `untrack` is deliberate: the draft is seeded once from `recipe` and then
47
+ // owned by the form. Re-seeding on every prop read would discard user edits.
48
+ let draft = $state<RecipeDraft>(untrack(() => draftFrom(recipe)));
49
+ let submitted = $state(false);
50
+ let formError = $state<string | null>(null);
51
+
52
+ // Reload the draft if the target recipe changes (e.g. navigating between edits).
53
+ let loadedId = $state(untrack(() => recipe?.id));
54
+ $effect(() => {
55
+ if (recipe?.id === loadedId) return;
56
+ loadedId = recipe?.id;
57
+ draft = draftFrom(recipe);
58
+ submitted = false;
59
+ formError = null;
60
+ });
61
+
62
+ const errors = $derived<RecipeErrors>(validateDraft(draft));
63
+ /** Errors are only surfaced after the first submit attempt. */
64
+ const shown = $derived<RecipeErrors>(submitted ? errors : {});
65
+ const problemCount = $derived(countErrors(errors));
66
+
67
+ /* ------------------------------------------------------- ingredient rows */
68
+
69
+ function addIngredient() {
70
+ if (draft.ingredients.length >= LIMITS.maxIngredients) return;
71
+ draft.ingredients = [...draft.ingredients, { name: '', measure: '' }];
72
+ }
73
+
74
+ function removeIngredient(index: number) {
75
+ // Always leave one row so the user has somewhere to type.
76
+ if (draft.ingredients.length <= 1) {
77
+ draft.ingredients = [{ name: '', measure: '' }];
78
+ return;
79
+ }
80
+ draft.ingredients = draft.ingredients.filter((_, position) => position !== index);
81
+ }
82
+
83
+ function moveIngredient(index: number, direction: -1 | 1) {
84
+ const target = index + direction;
85
+ if (target < 0 || target >= draft.ingredients.length) return;
86
+ const next = [...draft.ingredients];
87
+ [next[index], next[target]] = [next[target], next[index]];
88
+ draft.ingredients = next;
89
+ }
90
+
91
+ /* ------------------------------------------------------------- step rows */
92
+
93
+ function addStep() {
94
+ if (draft.steps.length >= LIMITS.maxSteps) return;
95
+ draft.steps = [...draft.steps, ''];
96
+ }
97
+
98
+ function removeStep(index: number) {
99
+ if (draft.steps.length <= 1) {
100
+ draft.steps = [''];
101
+ return;
102
+ }
103
+ draft.steps = draft.steps.filter((_, position) => position !== index);
104
+ }
105
+
106
+ function moveStep(index: number, direction: -1 | 1) {
107
+ const target = index + direction;
108
+ if (target < 0 || target >= draft.steps.length) return;
109
+ const next = [...draft.steps];
110
+ [next[index], next[target]] = [next[target], next[index]];
111
+ draft.steps = next;
112
+ }
113
+
114
+ /**
115
+ * Pastes a multi-line block into separate steps. Recipes are usually copied
116
+ * from somewhere, so honouring line breaks saves a lot of manual splitting.
117
+ */
118
+ function handleStepPaste(event: ClipboardEvent, index: number) {
119
+ const text = event.clipboardData?.getData('text/plain') ?? '';
120
+ const lines = text
121
+ .split(/\n+/)
122
+ .map((line) => line.replace(/^\s*(?:step\s*)?\d+[.)]?\s*/i, '').trim())
123
+ .filter(Boolean);
124
+ if (lines.length < 2) return;
125
+
126
+ event.preventDefault();
127
+ const next = [...draft.steps];
128
+ next.splice(index, 1, ...lines.slice(0, LIMITS.maxSteps - next.length + 1));
129
+ draft.steps = next;
130
+ }
131
+
132
+ /* ---------------------------------------------------------------- submit */
133
+
134
+ function handleSubmit(event: SubmitEvent) {
135
+ event.preventDefault();
136
+ submitted = true;
137
+ formError = null;
138
+
139
+ if (!isValid(errors)) {
140
+ // Move focus to the first field with a problem for keyboard users.
141
+ const firstInvalid = document.querySelector<HTMLElement>('[aria-invalid="true"]');
142
+ firstInvalid?.focus();
143
+ firstInvalid?.scrollIntoView({ block: 'center', behavior: 'smooth' });
144
+ return;
145
+ }
146
+
147
+ const ingredients: Ingredient[] = draft.ingredients
148
+ .filter((row) => row.name.trim() !== '')
149
+ .map((row) => ({ name: row.name.trim(), measure: row.measure.trim() }));
150
+
151
+ const minutes = parseOptionalCount(draft.minutes, LIMITS.minutesMax);
152
+ const servings = parseOptionalCount(draft.servings, LIMITS.servingsMax);
153
+
154
+ const result = onsubmit({
155
+ name: draft.name.trim(),
156
+ category: draft.category.trim(),
157
+ area: draft.area.trim(),
158
+ image: draft.image.trim() || undefined,
159
+ steps: draft.steps.map((step) => step.trim()).filter(Boolean),
160
+ ingredients,
161
+ tags: draft.tags,
162
+ minutes: minutes ?? undefined,
163
+ servings: servings ?? undefined,
164
+ notes: draft.notes.trim() || undefined
165
+ });
166
+
167
+ if (typeof result === 'string') formError = result;
168
+ }
169
+
170
+ const previewSteps = $derived(joinSteps(draft.steps.filter((step) => step.trim() !== '')));
171
+ </script>
172
+
173
+ <form class="form" onsubmit={handleSubmit} novalidate>
174
+ {#if submitted && problemCount > 0}
175
+ <div class="banner tone-error" role="alert">
176
+ <strong>{problemCount} {problemCount === 1 ? 'field needs' : 'fields need'} attention.</strong>
177
+ Fix the highlighted fields below and submit again.
178
+ </div>
179
+ {/if}
180
+
181
+ {#if formError}
182
+ <div class="banner tone-error" role="alert">{formError}</div>
183
+ {/if}
184
+
185
+ <fieldset class="card-surface">
186
+ <legend>The basics</legend>
187
+
188
+ <div class="stack">
189
+ <label class="field">
190
+ <span class="field-label">
191
+ Recipe name <span class="field-req" aria-hidden="true">*</span>
192
+ </span>
193
+ <input
194
+ class="input"
195
+ type="text"
196
+ bind:value={draft.name}
197
+ placeholder="e.g. Charred broccoli with tahini"
198
+ maxlength={LIMITS.nameMax + 20}
199
+ aria-invalid={shown.name ? 'true' : undefined}
200
+ aria-describedby={shown.name ? 'err-name' : undefined}
201
+ />
202
+ {#if shown.name}<span class="field-error" id="err-name">{shown.name}</span>{/if}
203
+ </label>
204
+
205
+ <div class="field-grid">
206
+ <label class="field">
207
+ <span class="field-label">Category <span class="field-req" aria-hidden="true">*</span></span>
208
+ <select class="select" bind:value={draft.category} aria-invalid={shown.category ? 'true' : undefined}>
209
+ <option value="">Choose a category…</option>
210
+ {#each categories as category (category)}
211
+ <option value={category}>{category}</option>
212
+ {/each}
213
+ </select>
214
+ {#if shown.category}<span class="field-error">{shown.category}</span>{/if}
215
+ </label>
216
+
217
+ <label class="field">
218
+ <span class="field-label">Cuisine <span class="field-req" aria-hidden="true">*</span></span>
219
+ <select class="select" bind:value={draft.area} aria-invalid={shown.area ? 'true' : undefined}>
220
+ <option value="">Choose a cuisine…</option>
221
+ {#each areas as area (area)}
222
+ <option value={area}>{area}</option>
223
+ {/each}
224
+ </select>
225
+ {#if shown.area}<span class="field-error">{shown.area}</span>{/if}
226
+ </label>
227
+ </div>
228
+
229
+ <div class="field-grid">
230
+ <label class="field">
231
+ <span class="field-label">Total time <span class="field-hint">minutes, optional</span></span>
232
+ <!--
233
+ Kept as `type="text"` with a numeric keypad: `bind:value` on a number
234
+ input coerces to a number, which would bypass the string-level
235
+ validation that reports "not a whole number" back to the user.
236
+ -->
237
+ <input
238
+ class="input"
239
+ type="text"
240
+ inputmode="numeric"
241
+ bind:value={draft.minutes}
242
+ placeholder="45"
243
+ aria-invalid={shown.minutes ? 'true' : undefined}
244
+ />
245
+ {#if shown.minutes}<span class="field-error">{shown.minutes}</span>{/if}
246
+ </label>
247
+
248
+ <label class="field">
249
+ <span class="field-label">Servings <span class="field-hint">optional</span></span>
250
+ <input
251
+ class="input"
252
+ type="text"
253
+ inputmode="numeric"
254
+ bind:value={draft.servings}
255
+ placeholder="4"
256
+ aria-invalid={shown.servings ? 'true' : undefined}
257
+ />
258
+ {#if shown.servings}<span class="field-error">{shown.servings}</span>{/if}
259
+ </label>
260
+ </div>
261
+
262
+ <label class="field">
263
+ <span class="field-label">Image URL <span class="field-hint">optional</span></span>
264
+ <input
265
+ class="input"
266
+ type="url"
267
+ bind:value={draft.image}
268
+ placeholder="https://…"
269
+ aria-invalid={shown.image ? 'true' : undefined}
270
+ />
271
+ {#if shown.image}
272
+ <span class="field-error">{shown.image}</span>
273
+ {:else if draft.image.trim()}
274
+ <span class="field-hint">A preview appears in the card below once the URL loads.</span>
275
+ {/if}
276
+ </label>
277
+
278
+ <div class="field">
279
+ <span class="field-label">Tags <span class="field-hint">up to {LIMITS.maxTags}</span></span>
280
+ <!--
281
+ Stencil tag editor. `values` is an array, so the bridge assigns it as a
282
+ DOM property; changes come back as a single `rkTagsChange` event.
283
+ -->
284
+ <WebComponent
285
+ tag="rk-tag-input"
286
+ props={{ values: draft.tags, maxTags: LIMITS.maxTags, placeholder: 'e.g. weeknight, one pot', error: shown.tags }}
287
+ events={{ rkTagsChange: ((event: CustomEvent<string[]>) => (draft.tags = event.detail)) as (event: CustomEvent<never>) => void }}
288
+ />
289
+ </div>
290
+ </div>
291
+ </fieldset>
292
+
293
+ <fieldset class="card-surface">
294
+ <legend>Ingredients</legend>
295
+
296
+ {#if shown.ingredients}
297
+ <p class="field-error">{shown.ingredients}</p>
298
+ {:else}
299
+ <p class="field-hint">At least {LIMITS.minIngredients} ingredients. Quantities are optional but helpful.</p>
300
+ {/if}
301
+
302
+ <ol class="rows">
303
+ {#each draft.ingredients as ingredient, index (index)}
304
+ <li class="row-item">
305
+ <div class="row-fields">
306
+ <input
307
+ class="input"
308
+ type="text"
309
+ bind:value={draft.ingredients[index].name}
310
+ placeholder="Ingredient"
311
+ aria-label={`Ingredient ${index + 1} name`}
312
+ aria-invalid={shown.ingredientRows?.[index] ? 'true' : undefined}
313
+ />
314
+ <input
315
+ class="input measure"
316
+ type="text"
317
+ bind:value={draft.ingredients[index].measure}
318
+ placeholder="Quantity"
319
+ aria-label={`Ingredient ${index + 1} quantity`}
320
+ />
321
+ </div>
322
+
323
+ <div class="row-controls">
324
+ <button class="icon-btn" type="button" onclick={() => moveIngredient(index, -1)} disabled={index === 0} aria-label={`Move ingredient ${index + 1} up`}>↑</button>
325
+ <button
326
+ class="icon-btn"
327
+ type="button"
328
+ onclick={() => moveIngredient(index, 1)}
329
+ disabled={index === draft.ingredients.length - 1}
330
+ aria-label={`Move ingredient ${index + 1} down`}
331
+ >
332
+
333
+ </button>
334
+ <button class="icon-btn is-danger" type="button" onclick={() => removeIngredient(index)} aria-label={`Remove ingredient ${index + 1}`}>✕</button>
335
+ </div>
336
+
337
+ {#if shown.ingredientRows?.[index]}
338
+ <p class="field-error row-error">{shown.ingredientRows[index]}</p>
339
+ {/if}
340
+ </li>
341
+ {/each}
342
+ </ol>
343
+
344
+ <button class="btn btn-sm" type="button" onclick={addIngredient} disabled={draft.ingredients.length >= LIMITS.maxIngredients}>
345
+ + Add ingredient
346
+ </button>
347
+ </fieldset>
348
+
349
+ <fieldset class="card-surface">
350
+ <legend>Method</legend>
351
+
352
+ {#if shown.steps}
353
+ <p class="field-error">{shown.steps}</p>
354
+ {:else}
355
+ <p class="field-hint">One step per box. Pasting several lines at once splits them into separate steps.</p>
356
+ {/if}
357
+
358
+ <ol class="rows">
359
+ {#each draft.steps as step, index (index)}
360
+ <li class="row-item step-item">
361
+ <span class="step-num" aria-hidden="true">{index + 1}</span>
362
+
363
+ <textarea
364
+ class="textarea"
365
+ bind:value={draft.steps[index]}
366
+ rows="2"
367
+ placeholder="Describe this step…"
368
+ aria-label={`Step ${index + 1}`}
369
+ aria-invalid={shown.stepRows?.[index] ? 'true' : undefined}
370
+ onpaste={(event) => handleStepPaste(event, index)}
371
+ ></textarea>
372
+
373
+ <div class="row-controls">
374
+ <button class="icon-btn" type="button" onclick={() => moveStep(index, -1)} disabled={index === 0} aria-label={`Move step ${index + 1} up`}>↑</button>
375
+ <button class="icon-btn" type="button" onclick={() => moveStep(index, 1)} disabled={index === draft.steps.length - 1} aria-label={`Move step ${index + 1} down`}>↓</button>
376
+ <button class="icon-btn is-danger" type="button" onclick={() => removeStep(index)} aria-label={`Remove step ${index + 1}`}>✕</button>
377
+ </div>
378
+
379
+ {#if shown.stepRows?.[index]}
380
+ <p class="field-error row-error">{shown.stepRows[index]}</p>
381
+ {/if}
382
+ </li>
383
+ {/each}
384
+ </ol>
385
+
386
+ <button class="btn btn-sm" type="button" onclick={addStep} disabled={draft.steps.length >= LIMITS.maxSteps}>+ Add step</button>
387
+
388
+ <label class="field notes-field">
389
+ <span class="field-label">Notes <span class="field-hint">optional, max {LIMITS.notesMax} characters</span></span>
390
+ <textarea
391
+ class="textarea"
392
+ bind:value={draft.notes}
393
+ rows="3"
394
+ placeholder="Substitutions, storage, who liked it…"
395
+ aria-invalid={shown.notes ? 'true' : undefined}
396
+ ></textarea>
397
+ {#if shown.notes}<span class="field-error">{shown.notes}</span>{/if}
398
+ </label>
399
+ </fieldset>
400
+
401
+ <div class="form-actions">
402
+ <button class="btn btn-primary" type="submit">{submitLabel}</button>
403
+ <button class="btn" type="button" onclick={oncancel}>Cancel</button>
404
+
405
+ <p class="small muted status" aria-live="polite">
406
+ {#if problemCount === 0}
407
+ Ready to save · {draft.ingredients.filter((row) => row.name.trim()).length} ingredients ·
408
+ {previewSteps ? draft.steps.filter((step) => step.trim()).length : 0} steps
409
+ {:else if submitted}
410
+ {problemCount} {problemCount === 1 ? 'problem' : 'problems'} remaining
411
+ {/if}
412
+ </p>
413
+ </div>
414
+ </form>
415
+
416
+ <style>
417
+ .form {
418
+ display: grid;
419
+ gap: 20px;
420
+ max-width: 820px;
421
+ }
422
+
423
+ fieldset {
424
+ border: 1px solid var(--rk-border);
425
+ margin: 0;
426
+ }
427
+
428
+ legend {
429
+ padding: 0 10px;
430
+ font-family: var(--rk-font-display);
431
+ font-size: 1.05rem;
432
+ font-weight: 650;
433
+ }
434
+
435
+ .banner {
436
+ padding: 12px 16px;
437
+ border-radius: var(--rk-radius);
438
+ font-size: 0.9rem;
439
+ }
440
+
441
+ .banner.tone-error {
442
+ background: var(--rk-danger-soft);
443
+ color: var(--rk-danger);
444
+ border: 1px solid var(--rk-danger);
445
+ }
446
+
447
+ .rows {
448
+ list-style: none;
449
+ margin: 14px 0;
450
+ padding: 0;
451
+ display: grid;
452
+ gap: 10px;
453
+ }
454
+
455
+ .row-item {
456
+ display: flex;
457
+ flex-wrap: wrap;
458
+ align-items: flex-start;
459
+ gap: 8px;
460
+ }
461
+
462
+ .row-fields {
463
+ display: flex;
464
+ gap: 8px;
465
+ flex: 1 1 260px;
466
+ min-width: 0;
467
+ }
468
+
469
+ .row-fields .input {
470
+ min-width: 0;
471
+ }
472
+
473
+ .measure {
474
+ flex: 0 0 130px;
475
+ }
476
+
477
+ .row-controls {
478
+ display: flex;
479
+ gap: 4px;
480
+ flex: 0 0 auto;
481
+ padding-top: 2px;
482
+ }
483
+
484
+ .icon-btn {
485
+ width: 34px;
486
+ height: 40px;
487
+ padding: 0;
488
+ border: 1px solid var(--rk-border);
489
+ border-radius: var(--rk-radius-sm);
490
+ background: var(--rk-surface);
491
+ color: var(--rk-ink-2);
492
+ font-size: 0.9rem;
493
+ cursor: pointer;
494
+ transition:
495
+ background var(--rk-transition),
496
+ color var(--rk-transition);
497
+ }
498
+
499
+ .icon-btn:hover:not(:disabled) {
500
+ background: var(--rk-surface-2);
501
+ color: var(--rk-primary);
502
+ }
503
+
504
+ .icon-btn.is-danger:hover:not(:disabled) {
505
+ background: var(--rk-danger-soft);
506
+ color: var(--rk-danger);
507
+ border-color: var(--rk-danger);
508
+ }
509
+
510
+ .icon-btn:disabled {
511
+ opacity: 0.4;
512
+ cursor: not-allowed;
513
+ }
514
+
515
+ .row-error {
516
+ flex: 1 1 100%;
517
+ margin: 0;
518
+ }
519
+
520
+ .step-item {
521
+ align-items: flex-start;
522
+ }
523
+
524
+ .step-num {
525
+ flex: 0 0 auto;
526
+ display: grid;
527
+ place-items: center;
528
+ width: 28px;
529
+ height: 40px;
530
+ color: var(--rk-muted);
531
+ font-weight: 700;
532
+ font-variant-numeric: tabular-nums;
533
+ }
534
+
535
+ .step-item .textarea {
536
+ flex: 1 1 240px;
537
+ min-width: 0;
538
+ }
539
+
540
+ .notes-field {
541
+ margin-top: 22px;
542
+ padding-top: 18px;
543
+ border-top: 1px solid var(--rk-border);
544
+ }
545
+
546
+ .form-actions {
547
+ display: flex;
548
+ flex-wrap: wrap;
549
+ align-items: center;
550
+ gap: 10px;
551
+ position: sticky;
552
+ bottom: 0;
553
+ padding: 14px 0;
554
+ background: linear-gradient(to top, var(--rk-bg) 60%, transparent);
555
+ }
556
+
557
+ .status {
558
+ margin-left: auto;
559
+ }
560
+ </style>
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ import type { RecipeSummary } from '$domain/recipe';
3
+ import RecipeCard from './RecipeCard.svelte';
4
+
5
+ interface Props {
6
+ recipes: RecipeSummary[];
7
+ /** Renders shimmer placeholders instead of cards. */
8
+ loading?: boolean;
9
+ skeletonCount?: number;
10
+ /** Per-card `actions` slot content. */
11
+ actions?: import('svelte').Snippet<[RecipeSummary]>;
12
+ badges?: import('svelte').Snippet<[RecipeSummary]>;
13
+ /** Shown when `recipes` is empty and not loading. */
14
+ empty?: import('svelte').Snippet;
15
+ }
16
+
17
+ let { recipes, loading = false, skeletonCount = 8, actions, badges, empty }: Props = $props();
18
+ </script>
19
+
20
+ {#if loading}
21
+ <div class="recipe-grid" aria-busy="true" aria-label="Loading recipes">
22
+ {#each Array.from({ length: skeletonCount }, (_, index) => index) as index (index)}
23
+ <div class="ghost card-surface">
24
+ <div class="skeleton ghost-media"></div>
25
+ <div class="skeleton ghost-line" style="width: 82%"></div>
26
+ <div class="skeleton ghost-line ghost-line-sm" style="width: 54%"></div>
27
+ </div>
28
+ {/each}
29
+ </div>
30
+ {:else if recipes.length === 0}
31
+ {@render empty?.()}
32
+ {:else}
33
+ <ul class="recipe-grid list-reset">
34
+ {#each recipes as recipe (recipe.id)}
35
+ <li><RecipeCard {recipe} {actions} {badges} /></li>
36
+ {/each}
37
+ </ul>
38
+ {/if}
39
+
40
+ <style>
41
+ .list-reset {
42
+ list-style: none;
43
+ margin: 0;
44
+ padding: 0;
45
+ }
46
+
47
+ .list-reset li {
48
+ display: flex;
49
+ }
50
+
51
+ .list-reset li > :global(*) {
52
+ flex: 1 1 auto;
53
+ }
54
+
55
+ .ghost {
56
+ padding: 0;
57
+ overflow: hidden;
58
+ display: grid;
59
+ gap: 10px;
60
+ align-content: start;
61
+ }
62
+
63
+ .ghost-media {
64
+ aspect-ratio: 4 / 3;
65
+ border-radius: 0;
66
+ }
67
+
68
+ .ghost-line {
69
+ height: 14px;
70
+ margin: 0 16px;
71
+ }
72
+
73
+ .ghost-line-sm {
74
+ height: 10px;
75
+ margin-bottom: 20px;
76
+ }
77
+ </style>