diff --git a/docs/local-application.md b/docs/local-application.md index 49f74e9..7267545 100644 --- a/docs/local-application.md +++ b/docs/local-application.md @@ -49,3 +49,28 @@ There is no recipe revision history. database from portable YAML. They are intended only for initial setup or an explicit restore. Running either command after application edits can discard newer SQLite-only data. + +## Windows dev-server notes + +Node is installed at `C:\Program Files\nodejs` but is not on the default +agent shell PATH. Prefix every npm/npx command: + +```bat +cmd /c "set PATH=C:\Program Files\nodejs;%PATH%&& npm run dev:app" +``` + +`astro dev` runs as a detached daemon (Astro 7). To stop it, find the PID +from the port and kill it directly — `scripts/restart-app.mjs` reads `/proc` +and does not work on Windows: + +```bat +netstat -ano | findstr :4322 +taskkill /PID /F /T +``` + +A long-running dev server inherited from another session can degrade +silently: pages render but Preact islands never hydrate (empty +`astro-island`, no console error). Before debugging component code, check +whether the recipe table hydrates and, if not, restart the dev server. The +URL pattern `?astro&type=script` returns 500 even when hydration works — it +is not a valid diagnostic. diff --git a/docs/site-spec/SITE_SPEC.md b/docs/site-spec/SITE_SPEC.md deleted file mode 100644 index 70cd531..0000000 --- a/docs/site-spec/SITE_SPEC.md +++ /dev/null @@ -1,217 +0,0 @@ -# meez Recipe App — Site Specification (Phase 1 Discovery) - -Source of truth: this document + reference screenshots under `generated/site-spec/screenshots/`. All colors/typography/geometry are exact computed values (CSS px at 1440×900 unless noted). Phase-1 scope: frontend UI discovery only. Network/API contracts intentionally excluded (user decision). - ---- - -## 1. Site Overview - -- **App**: meez — restaurant recipe management web app (authenticated SPA). -- **Base**: `https://app.getmeez.com/`; entered at `home?type=recipe&orderBy=last_viewed&direction=desc` (no redirect). -- **Framework**: React SPA in `#root`, **Material UI 5** components (`MuiGrid2`, `MuiAppBar`, `MuiChip`, `MuiTab`, `MuiDialog`, `MuiMenu`…) with JSS-generated class names (`jssNNNN`), webpack chunk bundles (`main.ddfec8b1.js`, `NNNN.hash.chunk.js`). No common framework globals detected; bundle naming is webpack. -- **Extensions/widgets observed**: Intercom chat iframe, candu A/B component (``), Google Tag Manager (`gtm.js`), Recurly billing (`recurly.js`, `recurly.css`), Google Material Icons font. -- **Auth**: logged-in session (owner identity shown as initials `NW` in list Owner columns). Title format: `{Page} - meez` (list pages), `{RecipeName} - Recipe - {Folder} - meez` (editor). -- **Core concept**: single "home" list view switched by `type` query param: `recipe`, `ingredient`, `recipebook`, `doc`, `purchased_item`. Recipe creation is a wizard (name → paste ingredients/prep → editor). Recipe editor is a two-column workspace with tabs. -- **Upgrade-gated features (never attempted, per user directive)**: Menus nav tab ("Upgrade" pill), New→Menu, New→Count Sheet, editor Nutrition tab ("Upgrade" suffix). Purchase-import services (Invoice Processing, Inventory integrations) mentioned in help copy as contact-gated. - -## 2. Route Inventory - -All list routes share the same shell (header + nav pill row + list). URL pattern: `/home?type={type}&orderBy={orderBy}&direction={dir}` (+ optional `query=`). - -| Route | URL (type=) | Purpose | Major unique components | Notes | -|---|---|---|---|---| -| Recipes | `recipe` | Recipe list | list rows (thumb, name, owner, last-viewed), row kebab menu, sort combobox, search | orderBy: last_viewed, name, date_created, modified, shared; 3-4 recipes | -| Ingredients | `ingredient` | Ingredient library | section header "Needs Attention:", svg type icons, Filter chip shows count `(1)` | 19-20 items; older rows show date (`Aug 12, 2026`) instead of relative time | -| Menus | — | Menu builder | — | **GATED** (`Upgrade` pill next to nav label); route not accessed | -| Recipe Books | `recipebook` | Recipe book collections | 5-column grid incl. "Recipes/Docs" count column | rows: "Sample Recipes" (2), "Get Started With Meez" (4) | -| Docs | `doc` | Help documents | 4-column grid, no Owner values | meez-authored docs (FAQ, how-tos) | -| New Purchase Items | `purchased_item` | Purchase imports needing attention | **empty state**: success message + "Tool Tip / Purchase items" help panel | no items → explanatory card; sort defaults to `created` | -| Recipe editor | `/recipes/{id}/steps?conceptId={id}` | Recipe editing workspace | 2-column editor, tab bar, ingredient table, prep method cards, additional details | title `{name} - Recipe - {folder} - meez`; see §8 | - -Navigation: header has logo/back icon (x32), "All" text tab, search box, right-side "New" cluster (add icon + contained "New" button); pill-tab row below lists the six library types with live count badges. `New` button opens a menu: Recipe, Recipe Book, Menu (gated), Doc, Count Sheet (gated), Import, Export. - -## 3. Global Design System - -- **No CSS custom properties** on `:root` (design tokens are hardcoded MUI/JSS values). -- **Body**: bg `#F3F3F3`, text `#050841`, font CircularCustCapNum, 16px, weight 300, line-height 20px, margin 0. -- **Header (MUI AppBar)**: bg `#FBFBFB`, height 80px, sticky, z-index 1100. -- **Primary brand blue**: `#3D5DF6` — contained buttons, links, selected dropdown items, active sort. -- **Navy**: `#050841` (body text, Share button bg), `#202962` (breadcrumb text, 12px/400). -- **Secondary gray**: `#A5A9C1` (icon buttons); header icon color `rgba(0,0,0,0.54)`; tab text `#3C4679` (500 weight). -- **Active pill bg**: `#DBE4FF`; active editor tab bg `#F1F5FE`; active row/select text blue. -- **Radius scale**: pills 100px (buttons) / 99px (nav chips) / 20px (chips) / 10px (dialogs) / 4px (tabs, selects) / 50% (icon buttons). -- **Shadows**: MUI elevation system (dialogs: `0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12)`; backdrop `rgba(0,0,0,0.5)`). -- **Buttons**: default text 16px/500; contained = blue bg, white text, pill; icon buttons 24-40px, radius 50%. -- **Spacing rhythm** (repeated values): 8px (row grid gap), 12px (chip/button padding X), 16px (menu option padding, card margins), 20px, 24px, 32px, 40px (dialog padding `30px 38px`), 80px, 160px (content column margins at ≥1440). - -## 4. Typography - -Primary family: **CircularCustCapNum** (custom, all UI text; Arial fallback for some icon buttons; Roboto also loaded for MUI defaults; Material Icons font for glyph icons). - -| Use | Size | Weight | Line-height | Color | Notes | -|---|---|---|---|---|---| -| Body/default | 16px | 300 | 20px | `#050841` | | -| Header buttons ("All", "New", "Done") | 16px | 500 | — | `#3C4679` / white / gray | | -| Nav pill labels | 16px | 400 | 30px | `rgba(0,0,0,0.87)` | h40 pills | -| List titles (links) | 16px | 300 | 20px | `#3D5DF6` (blue link) | | -| List secondary (owner, time) | 16px | 300 | 20px | `#3D5DF6` / `#050841` | time-ago is a blue link | -| Sort combobox | 14px | 400 | 23px | `#050841` | | -| Editor tab labels | 15px | 400 | — | `#050841` | active tab bg `#F1F5FE` | -| Editor input values | 14px | 400 | — | `rgba(0,0,0,0.87)` | | -| Breadcrumb | 12px | 400 | 16px | `#202962` | | -| Section headers ("Needs Attention:") | 16px | 300 | — | `#050841` | | - -Letter-spacing normal throughout; no text-transform observed. - -## 5. Colors - -| Token | Value | Usage | -|---|---|---| -| Ink | `#050841` | body text, Share button bg | -| Primary blue | `#3D5DF6` | contained buttons, links, selected states | -| Page bg | `#F3F3F3` | app background | -| Surface | `#FFFFFF` | rows, cards, dialogs | -| Header bg | `#FBFBFB` | app bar, list header row | -| Active pill | `#DBE4FF` | selected nav chip | -| Active tab bg | `#F1F5FE` | selected editor tab | -| Tab text | `#3C4679` | header "All" tab, unselected | -| Icon gray | `#A5A9C1` | icon buttons, filter icons | -| Icon faint | `rgba(0,0,0,0.54)` | header icon buttons | -| Disabled | `#DADADA` text on `#40B49A` bg | Done button (disabled state) | -| Success green | `#40B49A` | Done button bg | -| Row divider | `#F3F3F3` | row bottom border (0.74px) | -| Backdrop | `rgba(0,0,0,0.5)` | modal scrim | - -## 6. Spacing / Layout Conventions - -- **Content column**: at 1440px, list column is 1120px centered (x=160, margins 160). At ≤1024: margins collapse to 24px (list x=24, w=1057); at ≤768: full-width (x=0-24); at 390: x=16, w=358. -- **Grid lists**: CSS grid rows, 8px gap; list header row 57px bg `#FBFBFB`; data rows 57px white, bottom border `#F3F3F3`; cells vertically centered. -- **Recipe list columns** (1120 total): 36 (checkbox) | 112 (Type thumb 24×24) | 560 (Name) | 0 (hidden) | 112 (Owner initials) | 224 (Last viewed) | 36 (actions 24×24). -- **Recipe Book columns**: 56 | 84 | 516 | 144 (Recipes/Docs count) | 100 | 172 | 48. -- **Header**: logo icon 28×28 at x32 y26; "All" tab x72 y18; right cluster New icon+button at x1258/1274 y24 (each 32px tall); nav pill row y≈106 (h40, gap 8px); Filter chip right-aligned (h44). -- **Editor**: two columns — ingredient table left (x125-460), prep/details right (x≈778+). Tab bar h48 at y48. Header h80. -- **Padding norms**: chips `8px 16px 8px 12px`; dialog `30px 38px`; menu `8px 0`; menu options `6px 16px`; contained buttons `0 12px`. - -## 7. Reusable Components - -- **Nav pill chips** (MuiChip-filled/outlined): h40, radius 99px, label 16px/400; active bg `#DBE4FF`; live count in label (e.g., "Recipes 4"); Filter chip variant h44 radius 20px, icon+label. -- **List row**: checkbox (20×20 MUI) + type icon (24×24, svg for ingredients) + name link + owner initials (20px) + time link + 2 icon buttons (24×24, radius 50%): favorite/open + kebab. -- **Row kebab menu** (MUI Menu, portal): width 275, options 16px/300 pad `6px 16px`; items: Add to Recipe Book, Grant view access, Send a copy, Make a copy, Export, Print, Delete. -- **Sort combobox** (MUI Select): 14px, pad `4px 32px 5px 0`; dropdown 196w; selected option blue 400. -- **Search textbox**: 16px input, pad `8px 0 6px`, placeholder "Search "; commits on Enter → `query=` param; count badges live-filter. -- **Buttons**: contained (blue pill, 16px/500), text, icon (radius 50%); "New" = add icon + contained button pair; "Done" = green pill disabled until saved state; "Share" = navy pill (`#050841`). -- **Dialogs**: 700w radius 10px (create wizard), 900w (steps wizard); white bg, pad `30px 38px`; backdrop `rgba(0,0,0,0.5)`; footer buttons Cancel (text) + primary pill (250×48). -- **Form fields**: text inputs 14px, pad `10px 12px`, borderless underline (MUI); FilledInput variants in editor ("Qty", "Unit", "Station Name", "Tag Name" placeholders). -- **Empty state card**: white, pad `0 40px 80px`, title + help text (purchase items page). -- **Editor controls**: "Add Header"/"Add Note" buttons, "Bulk Add"/"Add Ingredients" (outlined, opens textarea dialog 583×320), Total Yield Qty+Unit inputs, toggles "Auto calculate total yield", "Calculate %". - -## 8. Per-Route Specification - -### 8.1 Recipes list (`/home?type=recipe`) -Header + nav + list as §6. Row: checkbox | Type thumb (24×24) | Name (blue link) | Owner initials | relative time link | action icons. Sort options: Last Modified, Last Viewed, Date Created, Last Modified, Date Shared (direction arrows keyboard_arrow_up/arrow_downward toggle asc/desc). Search filters live with `query=` param; nav badges update (Recipes 1…). Screenshots: `home@1440w.png` (3 rows), `home@1440w-sort-open.png`, `home@1440w-row-menu.png`, `home@1440w-search.png`, `home@1024w.png`, `home@768w.png`, `home@390w.png`. - -### 8.2 Ingredients (`/home?type=ingredient`) -Same shell; section header "Needs Attention:" (16px/300, y189); rows have SVG type icons; Filter chip shows `(1)`. Older items show `Aug 12, 2026` date format. Screenshots: `ingredients@1440w.png`, `ingredients@1024w.png`, `ingredients@768w.png`, `ingredients@390w.png`. - -### 8.3 Recipe Books (`/home?type=recipebook`) -5-column grid incl. Recipes/Docs count; loading state shows "Loading...". Screenshots: `recipe-books@1440w.png`, `recipe-books@390w.png`. - -### 8.4 Docs (`/home?type=doc`) -4-column grid; meez-authored docs; no Owner values. Screenshots: `docs@1440w.png`, `docs@390w.png`. - -### 8.5 New Purchase Items (`/home?type=purchased_item`) -Empty state: "Your purchase items are up to date!" + help panel (upload template / Invoice Processing Service / Inventory integrations — services contact-gated). Screenshots: `purchase-items@1440w.png`, `purchase-items@390w.png`. - -### 8.6 Recipe create wizard -1. **Create Recipe dialog** (700×636, r10): fields Recipe Name (583×40, 14px), Owner radio "Personal" (disabled for single-user), Folder radio "First Concept", "Add to Recipe Book (optional)" search (placeholder "Search existing recipe books or create new"); buttons Cancel + Next (250×48 blue pill). Screenshot: `recipe-create-modal@1440w.png`. -2. **Add Ingredients & Prep Method dialog** (900×742): Ingredients textarea (387×320) + Prep Method textarea, tip text "(note)" syntax and "To Garnish:" header syntax; buttons Skip + Add Recipe. Screenshot: `recipe-steps-wizard@1440w.png`. -3. On Add Recipe → `/recipes/{id}/steps?conceptId={id}` editor. - -### 8.7 Recipe editor (`/recipes/{id}/steps`) -- Header: logo/back icons, breadcrumb `Home / First Concept / Recipes` (12px/400 `#202962`), **Done** (green pill `#40B49A`, disabled in initial state), **Share** (navy pill `#050841`), New cluster, kebab. -- **Tab bar** (h48, y48): Prep Method (active bg `#F1F5FE`), Cost, UoM Equivalency, Nutrition **Upgrade** (gated). At ≤768 an **Ingredients** tab is prepended; at 390 tabs shrink to 70w each in a scrollable bar. -- **Left column — ingredient table**: header "Total Yield" with toggles "Auto calculate total yield" / "Calculate %"; columns Qty (83w) / Ingredient (220w, autocomplete → library match, e.g. "Test Flour"→"Flour") / Notes (232w); tip "Enter to complete, Tab to add a note"; Add Header / Add Note buttons; Add Ingredients (outlined) opens textarea dialog. -- **Right column**: "Add Cover Image" (landscape recommended), **Prep Method** section (step cards: "1." + "Drag & Drop or click to add an image or a video — File should not excede 200mb or 2 min"), Add Header / Add Note. -- **Additional Details** (right, y≈791): Shelf Life, Station Name input, Tag Name input, Owner accordion (radio "First Concept"). -- **Cost tab**: adds "Calculate %" toggle context; **UoM Equivalency tab**: "U of M Equivalency" rows (Weight = / Volume = / Each =), help text, "Standard Weight - Volume Conversion" toggle (locked to 8oz = 1 cup when on). -- Screenshots: `recipe-editor@1440w.png`, `recipe-editor-cost@1440w.png`, `recipe-editor-uom@1440w.png`, `recipe-editor@1024w.png`, `recipe-editor@768w.png`, `recipe-editor@390w.png`. - -## 9. Responsive Behavior - -Viewports emulated (CDP): 1440×900, 1024×768, 768×1024, 390×844 (real window 1157×744 @ dpr 1.35; captures at physical 1296×810 for 1440). - -| Breakpoint | Header | Nav | List column | Notes | -|---|---|---|---|---| -| ≥1440 | 80px, one row | pill row y106 | x160, w1120 | | -| 1024 | 80px | y106 | x24, w1057 (margins 160→24) | Filter chip stays right | -| 768 | **120px** (wraps to 2 rows) | y144 | x0, w768 full-bleed | editor: Ingredients tab appears; Filter moves to header row (y76) | -| 390 | **140px** (3 rows) | y148, horizontally scrollable (chips ≥390 wide) | x16, w358 | editor tabs 70w scrollable; New cluster icon-only (no text button found in header at 390) | - -Inferred breakpoints from layout transitions: ~1200 (margins collapse), ~900 (header grows, nav row wraps), ~600 (full-bleed list, compact tabs). - -## 10. Interaction / State Behavior - -- **Search**: type + Enter → URL `query=` param, list filters, count badges update, sort resets to `unsorted`; clearing restores. -- **Sort**: combobox pick → list reorders; arrow icons toggle direction (`direction=asc/desc`); URL reflects `orderBy`/`direction`. -- **Row kebab**: menu with 7 actions (Add to Recipe Book, Grant view access, Send a copy, Make a copy, Export, Print, Delete). Delete NOT exercised (only for test recipe cleanup). -- **Row hover**: second action icon reveals (24×24 buttons, both present in DOM). -- **New menu** (MuiListItemButton list, 375×348): Recipe / Recipe Book / Menu (gated) / Doc / Count Sheet (gated) / Import / Export; opens create wizard (Recipe) — DOM click required; real-mouse clicks failed to activate popover items on the relay (implementation detail for automation). -- **Create wizard**: modal steps; "Next" advances; "Add Recipe" creates draft and opens editor at `/recipes/{id}/steps?conceptId={id}`. -- **Editor dirty state**: Done button disabled initially (green `#40B49A` + gray text); enabled state not observed (not triggered). -- **Ingredient autocomplete**: typing in Ingredient cell matches library ingredients (e.g., "Test Flour" → "Flour"); Qty/Unit cells per row ("g", "ea"). -- **Filter chip**: no observable panel opened via mouse on relay; documented as inert in this environment (possible relay input quirk) — flag for manual verification. -- **Loading states**: list routes show "Loading..." while fetching; empty states (purchase items) show guidance card. -- **Dialogs**: ESC and Cancel close; backdrop `rgba(0,0,0,0.5)` click closes (standard MUI). - -## 11. Asset Inventory - -| Asset | Source/type | Dimensions | Where used | Behavior | -|---|---|---|---|---| -| CircularCustCapNum | `fonts.css` (custom @font-face) | — | all UI text | primary font | -| Material Icons | Google `icon?family=Material+Icons` | — | glyph icons | icon font | -| Roboto | MUI default | — | fallback | | -| MUI SvgIcon set | inline SVG, viewBox 0 0 24 24 | 20 (small) / 24 (medium) | all controls (add, kebab, arrows, filter, share) | `MuiSvgIcon-root` classes; icon font Material Icons also used | -| Logo | header icon button (svg) + "FC" text in editor breadcrumb | 28×28 / 27×27 | app bar left | | -| Thumbnails | list Type column | 24×24 div/svg | rows | ingredients = svg icons | -| Cover image | user upload (editor "Add Cover Image", landscape) | up to 200MB / 2min video | editor right column | drag&drop or click | -| Intercom widget | iframe | — | floating chat | third-party | -| candu component | `` | — | A/B overlays | third-party | -| gtm/recurly scripts | external JS | — | analytics/billing | third-party | - -## 12. Animation Behavior - -- Menus/popovers (New menu, kebab, sort dropdown): MUI Grow/Fade entry — sampled mid-transition `transition: all`, settling to `background-color 0.15s cubic-bezier(0.4,0,0.2,1)`; entry completes <300ms (opacity 1, transform none at rest). Standard MUI durations (~195-225ms, cubic-bezier(0.4,0,0.2,1)). -- Dialog open: MUI Dialog fade+zoom with backdrop fade; backdrop `rgba(0,0,0,0.5)`. -- Hover: row action icons reveal (opacity/visibility); no transform motion observed. -- No entrance animations on list content observed; loading = text "Loading...". - -## 13. Reference Screenshot Index - -All under `generated/site-spec/screenshots/` (PNG; 1440 captures at 1296×810 physical): - -| File | Route | Viewport | -|---|---|---| -| home@1440w.png | Recipes list | 1440×900 | -| home@1440w-sort-open.png | Recipes, sort dropdown open | 1440×900 | -| home@1440w-row-menu.png | Recipes, row kebab menu | 1440×900 | -| home@1440w-search.png | Recipes, search "banana" | 1440×900 | -| home@1024w.png / home@768w.png / home@390w.png | Recipes list | 1024 / 768 / 390 | -| ingredients@1440w.png, @1024w, @768w, @390w | Ingredients list | all four | -| recipe-books@1440w.png, @390w | Recipe Books | 1440 / 390 | -| docs@1440w.png, @390w | Docs | 1440 / 390 | -| purchase-items@1440w.png, @390w | Purchase items empty state | 1440 / 390 | -| recipe-create-modal@1440w.png | Create wizard step 1 | 1440×900 | -| recipe-steps-wizard@1440w.png | Create wizard step 2 | 1440×900 | -| recipe-editor@1440w.png, @1024w, @768w, @390w | Recipe editor | all four | -| recipe-editor-cost@1440w.png / recipe-editor-uom@1440w.png | Editor Cost / UoM tabs | 1440×900 | - -## 14. Unknowns / Things That Could Not Be Observed - -- **Upgrade-gated (not attempted, per directive)**: Menus nav + editor flow; New→Menu; New→Count Sheet; editor Nutrition tab; purchase-import services (Invoice Processing, Inventory integrations — contact-gated). -- **Filter chip**: clicking produced no observable panel in this environment (possible relay input quirk); needs manual verification — expected to open a filter drawer/menu. -- **Done button enabled state**: disabled in initial editor state; enabling trigger (save/dirty) not observed — manual check needed. -- **Row action icons** (24×24 pair): exact icon semantics (favorite vs open) not labeled in DOM; inferred from position. -- **Ingredient detail view**: row click navigation target (ingredient editor) not opened (read-only constraint on non-test data). -- **Recipe detail/share flows**: Share menu, Grant view access, Send a copy, Export/Print outputs not exercised. -- **Real window**: native 1157×744 @ dpr 1.35 — all captures used CDP-emulated viewports; physical vs CSS px scaling noted. -- **Test recipe** `OMP DISCOVERY TEST` (id 1210817): created for editor discovery, deleted at phase cleanup via row kebab → Delete → confirm dialog ("Delete recipe?" + "Recipe Deleted Successfully" toast). diff --git a/docs/site-spec/meez-home-measurements.md b/docs/site-spec/meez-home-measurements.md new file mode 100644 index 0000000..da67afb --- /dev/null +++ b/docs/site-spec/meez-home-measurements.md @@ -0,0 +1,207 @@ +# meez home page measurements + +Live measurements of the meez recipe home page +(`https://app.getmeez.com/home?type=recipe`) used as the styling target for +the Formulation app home page. Measured 2026-08-14 with an authenticated +relay-browser tab at a 1440×900 viewport, via `getComputedStyle` and +`getBoundingClientRect`. Re-measure before trusting these values on a new +design pass. + +## Global + +| Property | Value | +|---|---| +| body background | `#f3f3f3` | +| font family | `CircularCustCapNum, sans-serif` (proprietary; Formulation uses the Inter fallback stack) | +| base font | 16px / weight 300 / line-height 20px | +| text color | `#050841` | + +## Header (sticky top bar) + +- Height 80px, background `#fbfbfb`, no border, no shadow, z-index 1100. +- Inner toolbar: padding `0 24px`, min-height 48px. + +### Search box + +- Container: 504 × 40px, background `#fff`, border 1px solid `#f3f3f3`, + border-radius 4px, padding `0 12px`. +- Icon: 18px, `#8283a0`. +- Input: 16px / weight 400, line-height 23px, padding `8px 0 6px`, + color `rgba(0,0,0,.87)`; placeholder `#a5a9c1`. + +### New button + +- Height 32px, border-radius 100px, background `#3d5df6`, white text, + 16px / weight 500, padding `0 12px`. +- "add" icon 18px, 8px gap to label. + +### New dropdown + +- Paper: 375px wide, border-radius 4px, no border, MUI elevation-2 shadow: + `0 2px 1px -1px rgba(0,0,0,.2), 0 1px 1px 0 rgba(0,0,0,.14), 0 1px 3px 0 rgba(0,0,0,.12)`. +- List padding `22px 0`; items min-height 43.4px, padding `8px 25px`. +- Item icon: 24px, `#a5a9c1`. Label: 16px / weight 500, `#050841`. +- Hover background `#f1f5fe`. + +## Workspace layout + +- Content column 1120px wide, centered; page wrapper padding `0 32px 32px`. +- Tabs row starts ≈26px below the header; table header ≈46px below the tabs row. + +### Tab chips (workspace pills) + +- Chip: 40px tall, border-radius 99px, padding `8px 16px 8px 12px`, + gap 8px, 16px / weight 400, color `rgba(0,0,0,.87)`. +- Inactive: background `#fff`, border 1px solid `#ececec`. +- Active: background `#DBE4FF`, no border. +- No hover change (hover state identical to resting state). +- Icon: 20px circle, white SVG with 1.5px inner padding. Per type: + - recipe `#3C4679` + - ingredient `#3F908A` + - book `#F3A642` + - purchase `#3F908A` +- Label: 16px / weight 500, `#050841`, 8px right margin. +- Count: 13px / weight 500, `#a5a9c1`. + +### Filter chip + +- Transparent background, border-radius 20px, padding 10px. +- Funnel SVG 15×18 (viewBox `0 0 18 21`), `#050841`. +- Text 15px / weight 400, `#050841`. Border stays transparent on hover. + +## Directory table + +- Header row: 56.66px, background `#fbfbfb`, border-bottom 1px solid + `#f3f3f3`, grid gap 8px, no padding; checkbox cell 36px with 8px left padding. +- Column titles: 14px / weight 400, `#a5a9c1`, line-height 21px. +- Sort icon: 18px, `#a5a9c1`. +- Data row: 56.66px, background `#fff`, border-bottom 1px solid `#f3f3f3`, + gap 8px, no padding. **No hover background.** +- Selected row background `#f1f5fe`. +- meez columns: 36 (checkbox) | 112 (type) | 560 (name) | 112 (owner) | + 224 (last viewed) | 36 (actions). Formulation clones only checkbox, type, + name, and actions — Owner/Last Viewed are explicitly out of scope. +- Name text: 15px / weight 500, `#050841`, line-height 22.5px. The wrapping + link is `#3d5df6` but the name paragraph overrides it. +- Row type icon: 24px circle, white 20px SVG, same per-type colors as tabs. +- Checkbox: 20×20; unchecked outline `#ececec`, checked fill `#3d5df6` + with white check. +- Row action button (more_vert): 24×24, `#a5a9c1`, border-radius 50%, + hover background `rgba(0,0,0,.04)`. + +### Row action menu + +- Paper: 275px, border-radius 4px, elevation-2 shadow, list padding `8px 0`. +- Items: min-height 47.4px, padding `10px 30px`, label 16px / weight 500 + `#050841`, icon 24px `#a5a9c1`. +- Hover `#f1f5fe` (verified with real mouse input). Delete item `#f63d48`. + +### Delete dialog + +- Paper: 700px, border-radius 10px, padding `30px 38px`, MUI elevation-24 + shadow: + `0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12)`. +- Backdrop `rgba(0,0,0,.5)`. +- Title: 28px / weight 700, `#202962`, line-height 39px. +- Subtitle: 15px / weight 400, `#050841`. +- Cancel: transparent, 15px / weight 500, `#050841`, 52px gap to Delete. +- Delete: `#f63d48` pill, 16px / weight 500, padding `16px 24px`, + min-width 250px, height 48px, border-radius 100px. + +## Dropdown panels (filter / item-type menus) + +- 300px wide, border-radius 4px, no border, elevation-2 shadow, + padding `14px 0`. +- Labels: 14px; hover `#f1f5fe`. Apply button `#3d5df6`. + +## Measurement cautions + +- MUI hover states (menu items, chips) only appear with real mouse input + (CDP mouse events), not synthetic `mouseover` dispatch. +- The relay tab cannot screenshot while not visible; use computed styles + and rects. +- Re-measure on any new pass: these are point-in-time values from one + authenticated account's render. + +## Recipe read view (`/recipes/{id}/steps`) + +Measured on the same account (recipe "Marinated Pork Belly"), same viewport +rules apply. Formulation's counterpart: `/app/recipes/{id}/` (read mode). + +### Header (48px, background `#fbfbfb`) + +- Breadcrumb links "Home / Recipes": 12px / weight 400, `#202962`, + line-height 22.5px; "/" separators `#95969c`, margin 0 4px. +- Edit button: white pill, 1px `#3d5df6` border, radius 100px, height 32px, + padding 0 12px, text 15px / weight 500 `#3d5df6`, edit icon 16px with 8px + right margin. +- Share button (not cloned): `#050841` pill, white 16px / weight 500. + +### Layout + +- Body `#f3f3f3`; content wrapper starts 16px below the header. +- Two 50/50 columns, both background `#fbfbfb`; right column has a 1px + `#f3f3f3` left border. Left padding `0 36px 35px`, right `0 32px 32px`. +- meez keeps both columns at 1027px viewport width (stacking breakpoint is + lower than Formulation's old 1280px). + +### Left column (formula) + +- Title: 28px / weight 900, `#050841`, line-height 42px; owner line + 12px / weight 500 directly below. +- Batch/yield row: labels 14px / weight 400 `#050841` with 15px right + padding; "1x" batch value 14px / weight 500 with `#a5a9c1` underline; + yield value 15px / weight 500. +- Ingredient table: no header row, no row borders. Component heading rows + 18px / weight 500, line-height 27px, bottom border `#fdfdfd`. Ingredient + rows 40.66px tall, cell padding 8px 0 (name cell +10px left). Quantity + number 15px / weight 300, unit 14px. Name link 15px / weight 400, + `rgba(0,0,0,.87)`. + +### Right column (tabs + method) + +- Tab bar: 48px, at the top of the column. Tabs: icon 24px in a 45px box + (8px padding, 5px right margin), label 13px / weight 400 `#050841`, + padding 0 10px 0 0, 1px `#f3f3f3` right border between tabs. + Active: background `#f1f5fe` + 1px `#3d5df6` bottom border. +- Panel heading row: h2 22px / weight 700, line-height 33px; step count + 16px / weight 700 `#a5a9c1` with 8px left margin; 34px bottom margin. +- Step section headings: 18px / weight 500, line-height 27px. +- Steps: number 16px / weight 700, text 16px / weight 300, `#050841`; + separators 1px `#f3f3f3` with 16px vertical margins. +- Additional details: heading 18px / weight 500; label 14px / weight 500, + value 15px / weight 500, `#050841`. + +### Tab icons (Material paths, white-on-inherit) + +- Prep Method: list icon, viewBox 24 (`M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 + 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5 + 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5 + 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14 + v-2H7zm0-8v2h14V5z`). +- Cost: attach_money icon, viewBox 24. +- UoM Equivalency: custom balance icon, viewBox 24. +- Nutrition: custom heart icon, viewBox 20×18. +### Cost panel (`/recipes/{id}/cost`, measured in the second pass) + +- Panel padding `35px 32px 0` (same `#fbfbfb` column). +- h2 22px / weight 700, line-height 33px; description 14px / weight 400 + `#95969c`; ≈20px gap before the list. +- Column header row: padding `0 0 4px 14px`, bottom border `#f3f3f3`, + labels 12px / weight 500 `#a5a9c1`; "Expand all | Collapse all" links + 12px / weight 500 `#3d5df6`. +- Rows (MUI accordions): min-height 48px, summary padding `12px 32px`, + name 15px / weight 400 `#050841`, cost value 15px / weight 400. + Detail labels ("Purchase Item Name", etc.) 14px / weight 400 `#a5a9c1`. + +### UoM Equivalency panel (`/recipes/{id}/equivalency`) + +- h2 "U of M Equivalency" 22px / weight 700, line-height 33px. +- Description 14px muted `#95969c`. +- Equivalency matrix: three value columns (Weight / Volume / Each) with + "=" separators; columns ≈124px wide, 79.5px tall. + +### Nutrition tab + +Gated ("Upgrade" teaser) on the measured account — no read-mode nutrition +panel exists in meez to match. diff --git a/public/fonts/circular/CircularCustCapNum-Black.woff2 b/public/fonts/circular/CircularCustCapNum-Black.woff2 new file mode 100644 index 0000000..e4e9187 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Black.woff2 differ diff --git a/public/fonts/circular/CircularCustCapNum-Bold.woff2 b/public/fonts/circular/CircularCustCapNum-Bold.woff2 new file mode 100644 index 0000000..459da90 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Bold.woff2 differ diff --git a/public/fonts/circular/CircularCustCapNum-Book.woff2 b/public/fonts/circular/CircularCustCapNum-Book.woff2 new file mode 100644 index 0000000..b2bbec1 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Book.woff2 differ diff --git a/public/fonts/circular/CircularCustCapNum-Light.woff2 b/public/fonts/circular/CircularCustCapNum-Light.woff2 new file mode 100644 index 0000000..caae556 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Light.woff2 differ diff --git a/public/fonts/circular/CircularCustCapNum-Medium.woff2 b/public/fonts/circular/CircularCustCapNum-Medium.woff2 new file mode 100644 index 0000000..3d2eff4 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Medium.woff2 differ diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs new file mode 100644 index 0000000..5b4ca35 --- /dev/null +++ b/scripts/dev-server.mjs @@ -0,0 +1,15 @@ +import { dev } from "astro"; + +try { + const server = await dev({ + configFile: "astro.app.config.mjs", + server: { + port: 4322, + host: true + } + }); + console.log("Astro dev server is running on http://localhost:4322/app/"); +} catch (err) { + console.error("Failed to start Astro dev server:", err); + process.exit(1); +} diff --git a/scripts/extract-meez-tokens.js b/scripts/extract-meez-tokens.js new file mode 100644 index 0000000..dfcc262 --- /dev/null +++ b/scripts/extract-meez-tokens.js @@ -0,0 +1,83 @@ +/** + * Meez Design Token & Style Extractor + * + * Paste this snippet into the DevTools Console while viewing Meez + * (e.g. https://app.getmeez.com/home?type=recipe or a recipe detail page). + * It will collect computed styles, layout metrics, and SVG icons and copy + * a formatted JSON report to your clipboard. + */ +(() => { + const getStyle = (el, prop) => el ? window.getComputedStyle(el).getPropertyValue(prop) : null; + + const extractComponent = (selector, name) => { + const el = document.querySelector(selector); + if (!el) return null; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return { + name, + selector, + dimensions: { width: rect.width, height: rect.height }, + typography: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + lineHeight: style.lineHeight, + letterSpacing: style.letterSpacing, + color: style.color, + }, + surface: { + backgroundColor: style.backgroundColor, + borderRadius: style.borderRadius, + border: `${style.borderWidth} ${style.borderStyle} ${style.borderColor}`, + boxShadow: style.boxShadow, + }, + spacing: { + padding: style.padding, + margin: style.margin, + gap: style.gap, + } + }; + }; + + // Collect key UI elements on current page + const report = { + url: window.location.href, + timestamp: new Date().toISOString(), + global: { + bodyBackground: getStyle(document.body, 'background-color'), + fontFamily: getStyle(document.body, 'font-family'), + fontSize: getStyle(document.body, 'font-size'), + color: getStyle(document.body, 'color'), + }, + components: { + header: extractComponent('header, [role="banner"], .MuiAppBar-root', 'Header / Top Bar'), + searchBox: extractComponent('input[type="search"], input[placeholder*="Search"]', 'Search Input'), + newButton: extractComponent('button:has(svg), a:has(svg)', 'New Button'), + tableHeader: extractComponent('[role="rowgroup"] [role="row"]:first-child, thead tr', 'Table Header'), + tableRow: extractComponent('[role="rowgroup"] [role="row"]:not(:first-child), tbody tr:first-child', 'Table Data Row'), + tabPillActive: extractComponent('.MuiChip-root, [role="tab"][aria-selected="true"]', 'Active Tab / Pill'), + }, + svgIcons: Array.from(document.querySelectorAll('svg')).slice(0, 30).map((svg, idx) => ({ + index: idx, + viewBox: svg.getAttribute('viewBox'), + width: svg.getAttribute('width') || svg.clientWidth, + height: svg.getAttribute('height') || svg.clientHeight, + fill: getStyle(svg, 'fill') || getStyle(svg, 'color'), + paths: Array.from(svg.querySelectorAll('path')).map(p => p.getAttribute('d')), + ariaLabel: svg.getAttribute('aria-label') || svg.closest('button, a')?.getAttribute('aria-label') || '' + })) + }; + + console.log('=== MEEZ EXTRACTED TOKENS ===', report); + const jsonStr = JSON.stringify(report, null, 2); + if (navigator.clipboard) { + navigator.clipboard.writeText(jsonStr).then(() => { + console.log('✅ Tokens copied to clipboard!'); + }).catch(() => { + console.log('Copy to clipboard failed. Access report via window.__meezReport'); + }); + } + window.__meezReport = report; + return report; +})(); diff --git a/src/application/pages/app/index.astro b/src/application/pages/app/index.astro index aec05ed..e62f6dc 100644 --- a/src/application/pages/app/index.astro +++ b/src/application/pages/app/index.astro @@ -2,6 +2,18 @@ export const prerender = false; import BaseLayout from "../../../layouts/BaseLayout.astro"; import EntityDirectory from "../../../components/EntityDirectory"; +const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase",string> = { + recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z", + ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z", + book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z", + purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z" +}; +const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase",string|undefined> = { + recipe:"scale(1.1, 1.1) translate(4px, 3.5px)", + ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)", + book:"scale(1.1, 1.1) translate(3.5px, 2.5px)", + purchase:undefined +}; import { openDatabase } from "../../../lib/database"; import { readOnlyMode } from "../../../lib/runtime"; import { titleCase } from "../../../lib/format"; @@ -23,8 +35,8 @@ const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_ FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[]; database.close(); -const requested=Astro.url.searchParams.get("type")??"ingredient"; -const type=["recipe","ingredient","book","purchase"].includes(requested)?requested:"ingredient"; +const requested=Astro.url.searchParams.get("type"); +const type=["recipe","ingredient","book","purchase"].includes(requested??"")?requested:undefined; const query=(Astro.url.searchParams.get("q")??"").trim(); const normalizedQuery=query.toLocaleLowerCase(); const validSearchTypes=["recipe","ingredient","book","purchase"]; @@ -34,60 +46,66 @@ const attention=Astro.url.searchParams.get("attention")==="1", missingCost=Astro const filtering=attention||missingCost||noUsda||unused||emptyRecipe||placeholderSteps; const filteredIngredients=ingredients.filter((ingredient)=>{if(!filtering)return true;const selected=[missingCost&&ingredient.price_count===0,noUsda&&ingredient.nutrition_count===0,unused&&ingredient.recipe_count===0].filter(Boolean);return missingCost||noUsda||unused?selected.length>0:ingredient.price_count===0||ingredient.nutrition_count===0||ingredient.recipe_count===0;}); const filteredRecipes=recipes.filter(recipe=>!filtering||(emptyRecipe&&recipe.item_count===0)||(placeholderSteps&&recipe.placeholder_count>0)||(!emptyRecipe&&!placeholderSteps&&(recipe.item_count===0||recipe.placeholder_count>0))); -const tabs=[ - {type:"recipe",label:"Recipes",count:recipes.length,icon:"▦",kind:"recipe"}, - {type:"ingredient",label:"Ingredients",count:ingredients.length,icon:"●",kind:"ingredient"}, - {type:"book",label:"Recipe books",count:books.length,icon:"▣",kind:"book"}, - {type:"purchase",label:"Purchase items",count:purchases.length,icon:"$",kind:"purchase"}, +const FILTER_ICON_PATH="M18 6.0201C18 4.81608 17.1873 3.79266 16.0736 3.46156L16.0736 0.722412C16.0736 0.301005 15.7425 -9.86801e-08 15.3211 -1.171e-07C14.8996 -1.35521e-07 14.5685 0.331105 14.5685 0.752512L14.5685 3.49166C13.4548 3.79266 12.6421 4.84618 12.6421 6.0502C12.6421 7.28432 13.4548 8.30774 14.5685 8.63884L14.5685 19.7459C14.5685 20.1673 14.8996 20.4984 15.3211 20.4984C15.7425 20.4984 16.0736 20.1673 16.0736 19.7459L16.0736 8.63884C17.1873 8.27764 18 7.25422 18 6.0201ZM16.495 6.0502C16.495 6.68231 15.9833 7.22412 15.3211 7.22412C14.6588 7.22412 14.1471 6.68231 14.1471 6.0502C14.1471 5.41809 14.6588 4.87628 15.3211 4.87628C15.9833 4.87628 16.495 5.41809 16.495 6.0502Z"; +const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"}>=[ + {type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"}, + {type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"}, + {type:"book",label:"Recipe books",count:books.length,kind:"book"}, + {type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"}, ]; const allSearchResults=normalizedQuery ? [ - ...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"recipe",label:"Recipe",icon:"▦",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})), - ...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})), - ...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"book",label:"Recipe book",icon:"▣",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:"/app/?type=book"})), - ...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"purchase",label:"Purchase item",icon:"$",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})), + ...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})), + ...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})), + ...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})), + ...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})), ].sort((a,b)=>a.name.localeCompare(b.name)):[]; const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults; -const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const,icon:"●"})); -const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const,icon:"▦"})); -const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const,icon:"▣"})); -const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const,icon:"$"})); +const searchRows=searchResults.map(({id,kind,name,href,label,detail})=>({id,name,href,kind,detail:`${label} · ${detail}`})); +const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const})); +const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const})); +const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const})); +const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const})); --- -
-
+
- ☷   Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""} -
+ Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""} + -
{filteringSearchTypes&&All types}
+
{filteringSearchTypes&&All types}
{!readOnlyMode&&
- New + New
} +
-
+
diff --git a/src/application/pages/app/ingredients/[id].astro b/src/application/pages/app/ingredients/[id].astro index 6a51087..1a945fe 100644 --- a/src/application/pages/app/ingredients/[id].astro +++ b/src/application/pages/app/ingredients/[id].astro @@ -126,29 +126,333 @@ const prepDisplay = prep.map((row) => { return measure ? `${number(measure.quantity)} ${unitById.get(measure.unit_id)?.symbol ?? measure.unit_id}` : "—"; }; return {...row,weight:showMeasure("mass"),volume:showMeasure("volume"),each:showMeasure("count")}; -}); +});const RECIPE_TAB_ICONS = { + overview: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z", + costing: "M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4", + equivalencies: "M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z", + nutrition: "M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z" +}; +const RECIPE_TAB_VIEWBOX = { overview: "0 0 24 24", costing: "0 0 24 24", equivalencies: "0 0 24 24", nutrition: "0 0 20 18" }; +const recipeTabIcon = (name: string) => ``; database.close(); --- - -
- -

← Ingredients

{editing?:

{titleCase(ingredient.name)}

}
{!readOnlyMode&&
{editing?:✎ Edit}{editing&&
}
}
- {message&&
{message}
}{error&&
{error}
} -
-
- {editing&&
} -

Prep Actions

Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.

{editing?<>
{prepDisplay.map(x=>{[x.weight,x.volume,x.each].map(value=>)})}
Prep ActionYield %WeightVolumeEach
%

Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.

:prep.length?{prepDisplay.map(x=>)}
Prep ActionYield %WeightVolumeEach
{x.name}{x.notes&&{x.notes}}{number(x.yield_factor*100)}%{x.weight}{x.volume}{x.each}
:

No prep actions defined.

}
-

Additional Details

{editing?
:<>
Recipes On {usedIn.length}{usedIn.length?:

This ingredient is not used by a recipe.

}
{ingredientTags.length>0&&
Tags{ingredientTags.map(tag=>{tag})}
}{ingredient.description&&
Description

{ingredient.description}

}
Ingredient aliases {aliases.length}{aliases.length?
    {aliases.map(x=>
  • {x.name}
  • )}
:

No aliases.

}
}
+ +
+ +
+ {editing ? ( + + ) : !readOnlyMode && ( + Edit + )} + {editing && ( +
+ +
+
+ + +
+
+
+ )} +
+
+ +
+
+
+

← Ingredients

+ {editing ? ( + + ) : ( +

{titleCase(ingredient.name)}

+ )} +
+
+ + {message &&
{message}
} + {error &&
{error}
} + +
+ {editing && ( +
+ + + +
+ )} + +
+

Prep Actions

+

Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.

+ {editing ? ( + <> + +
+ + + + + + + + + + + + + {prepDisplay.map(x => ( + + + + {[x.weight, x.volume, x.each].map(value => ( + + ))} + + + ))} + +
Prep ActionYield %WeightVolumeEach
+ + + + + + % + + + + + +
+
+

Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.

+ + ) : prep.length ? ( + + + + + + + + + + + + {prepDisplay.map(x => ( + + + + + + + + ))} + +
Prep ActionYield %WeightVolumeEach
{x.name}{x.notes && {x.notes}}{number(x.yield_factor * 100)}%{x.weight}{x.volume}{x.each}
+ ) : ( +

This ingredient currently has no prep actions. Edit ingredient to add prep actions.

+ )} +
+ +
+

Additional Details

+ {editing ? ( +
+ + + +
+ ) : ( + <> +
+ Recipes On {usedIn.length} + {usedIn.length ? :

This ingredient is not used by a recipe.

} +
+ {ingredientTags.length > 0 && ( +
+ Tags + {ingredientTags.map(tag => {tag})} +
+ )} + {ingredient.description && ( +
+ Description +

{ingredient.description}

+
+ )} +
+ Ingredient aliases {aliases.length} + {aliases.length ?
    {aliases.map(x =>
  • {x.name}
  • )}
:

No aliases.

} +
+ + )} +
- +
+ +
+
+ + + + +
+ +
+
+

Ingredient Cost

+

Purchase packages, usable yield, and current prices.

+ {editing ? ( +
+
+ + Add purchase item + +
+ {purchases.map(x => ( +
+
+

{x.name}

+
+ + +
+
+ +
+ ))} +
+ ) : purchases.length ? ( + purchases.map(x => ( +
+ + {x.name} + {x.supplier_id || "No supplier"} · {x.status} + + + {x.latest_price != null ? `${x.latest_currency} ${Number(x.latest_price).toFixed(2)}` : "No price"} + {number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol ?? x.package_unit_id}{x.units_per_case > 1 ? ` × ${x.units_per_case}` : ""} + +
+ )) + ) : ( +

No purchase cost has been entered.

+ )} +
+ +
+

U of M Equivalency

+

XX Weight = XX Volume = XX Each
You can define a custom ingredient conversion from weight to volume and to a pc/each of the ingredient.

+
+ {densities.map(x => ( +

+ {number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol ?? x.volume_unit_id} + = + {number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol ?? x.mass_unit_id} + {x.state || "Density"} · sourced density +

+ ))} + {conversionRows.map(x => ( +
+

+ {number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol ?? x.from_unit_id} + = + {number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol ?? x.to_unit_id} + {x.state || "Conversion"} · {x.isManual ? "manual" : x.source.title || "sourced"} +

+ {editing && x.isManual && ( +
+ +
+ + +
+
+ )} +
+ ))} +
+ {!densities.length && !conversions.length &&

No equivalencies have been defined.

} + {editing && ( +
+ + Add Equivalency +
+ + + + = + + + + + +
+
+ )} +
+ +
+

Nutrition

+

Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.

+ {editing && ( +
+ + + {usdaMapping?.sourceUrl && Open USDA ↗} +
+ )} + {usdaMapping ? ( +
+
+
+ {usdaMapping.source.title} + {usdaMapping.status} · USDA FoodData Central +
+ {usdaMapping.sourceUrl && View source ↗} +
+ {usdaMapping.nutrients.length ? ( +
+ {usdaMapping.nutrients.map((entry: [string, number]) => { + const [key, value] = entry; + const [label, unit] = nutrientLabels[key] ?? [key.replaceAll("_", " "), ""]; + return
{label}
{number(value)} {unit}
; + })} +
+ ) : ( +

No reviewed nutrient values stored.

+ )} +
+ {usdaMapping.source.external_id && Record {usdaMapping.source.external_id}} + {usdaMapping.source.retrieved_at && Retrieved {usdaMapping.source.retrieved_at}} +
+
+ ) : ( +

No USDA record mapped.{editing && " Enter an FDC ID above."}

+ )} +
+
-{editing&&} - + window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()}); + +} +
diff --git a/src/application/pages/app/recipes/[id].astro b/src/application/pages/app/recipes/[id].astro index 9ccbd52..0e92970 100644 --- a/src/application/pages/app/recipes/[id].astro +++ b/src/application/pages/app/recipes/[id].astro @@ -1,7 +1,7 @@ --- export const prerender = false; import BaseLayout from "../../../../layouts/BaseLayout.astro"; -import { databaseProjection, duplicateRecipe, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database"; +import { databaseProjection, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database"; import { recipeStructure } from "../../../../lib/database"; import RecipeStructureEditor from "../../../../components/RecipeStructureEditor"; import RecipeCalculator, { LiveCostValues, LiveNutritionValues } from "../../../../components/RecipeCalculator"; @@ -21,16 +21,12 @@ let error: string | undefined; if (Astro.request.method === "POST") { try { const form = await Astro.request.formData(); - if(form.get("intent")==="media"){ - const url=String(form.get("url")??"").trim(),mediaType=String(form.get("media_type")??"image"),stepId=String(form.get("step_id")??"").trim()||null;if(!url)throw new Error("Media URL is required.");if(!["image","video"].includes(mediaType))throw new Error("Invalid media type."); - const position=(database.prepare("SELECT coalesce(max(position),0)+1 position FROM recipe_media WHERE recipe_id=?").get(id) as any).position;database.prepare("INSERT INTO recipe_media(recipe_id,id,step_id,media_type,url,caption,position) VALUES (?,?,?,?,?,?,?)").run(id,`media_${Date.now()}`,stepId,mediaType,url,String(form.get("caption")??"").trim()||null,position);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303); - } if(form.get("intent")==="additional"){ const row=database.prepare("SELECT source_json FROM recipes WHERE id=?").get(id) as any,source=JSON.parse(row.source_json??"{}"); const shelfQuantity=Number(form.get("shelf_quantity")),shelfUnit=String(form.get("shelf_unit")??"").trim(); if(shelfQuantity>0&&shelfUnit)source.shelf_life={duration:{quantity:shelfQuantity,unit_id:shelfUnit},storage_condition:String(form.get("storage_condition")??"").trim()||undefined};else delete source.shelf_life; const notes=String(form.get("notes")??"").split("\n").map(value=>value.trim()).filter(Boolean); - database.prepare("UPDATE recipes SET station=?,cover_media_url=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,String(form.get("cover_media_url")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id); + database.prepare("UPDATE recipes SET station=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id); refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303); } if (form.get("intent") === "duplicate") { @@ -117,9 +113,8 @@ const ingredientOptions = (database.prepare("SELECT id, name FROM ingredients WH const recipeOptions = database.prepare("SELECT id, title AS name FROM recipes WHERE deleted_at IS NULL ORDER BY title").all() as Array<{ id: string; name: string }>; const prepActionOptions = database.prepare("SELECT id, name FROM prep_actions ORDER BY name").all() as Array<{ id: string; name: string }>; const recipeConversions=database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[]; -const additional=database.prepare("SELECT station,cover_media_url,notes_json,source_json FROM recipes WHERE id=?").get(id) as any; +const additional=database.prepare("SELECT station,notes_json,source_json FROM recipes WHERE id=?").get(id) as any; const additionalSource=JSON.parse(additional.source_json??"{}"),shelfLife=additionalSource.shelf_life; -const media=database.prepare("SELECT * FROM recipe_media WHERE recipe_id=? ORDER BY position").all(id) as any[]; const projection = databaseProjection(database) as { recipes:Recipe[]; ingredients:Ingredient[]; units:Unit[]; sourceMappings:SourceMapping[]; purchaseItems:PurchaseItem[]; prepActions:PrepAction[] }; const domainRecipe = projection.recipes.find((entry) => entry.id === id)!; const recipeMap = new Map(projection.recipes.map((entry) => [entry.id, entry])); @@ -149,42 +144,297 @@ database.close(); const categories = JSON.parse(recipe.categories_json).join(", "); const tags = JSON.parse(recipe.tags_json).join(", "); const saved = Astro.url.searchParams.get("saved") === "1"; +const RECIPE_TAB_ICONS={ + formula:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z", + method:"M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14v-2H7zm0-8v2h14V5z", + costing:"M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4", + equivalencies:"M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z", + nutrition:"M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z" +}; +const RECIPE_TAB_VIEWBOX={formula:"0 0 14 16",method:"0 0 24 24",costing:"0 0 24 24",equivalencies:"0 0 24 24",nutrition:"0 0 20 18"}; +const recipeTabIcon=(name:string)=>``; --- -
- -

← Recipes

{editing?:

{recipe.title}

}
{!readOnlyMode&&
{editing?:✎ Edit}
}
-
{editing?<>:<>}
- {editing?<>
-
- - {saved &&
Changes saved.
} - {error &&
{error}
} -
Finished Yield{autoYield&&Calculated from convertible ingredient quantities}
- -
-
Auto calculate total yield{autoYield&&Revert to original and disable auto calculate}
-
-
-
-

UoM Equivalency

Define how this finished recipe converts between weight, volume, and portions.

{recipeConversions.map(x=>
{x.from_quantity} {x.from_unit_id}={x.to_quantity} {x.to_unit_id}{x.notes}
)}
=
-
-

Additional Details

Shelf Life
- :<>

Prep Method {domainRecipe.steps.length}

    {domainRecipe.steps.map(step=>
  1. {step.order}.{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=>
    {entry.media_type==="image"?{entry.caption??""}/:
    )}
  2. )}

UoM Equivalency

{recipeConversions.length?recipeConversions.map(x=>
{x.from_quantity} {x.from_unit_id}={x.to_quantity} {x.to_unit_id}{x.notes}
):

No recipe-level equivalencies have been defined.

}
{additional.cover_media_url&&
}

Additional details

{additional.station&&

Station{additional.station}

}{shelfLife&&

Shelf life{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}

}{JSON.parse(additional.notes_json??"[]").length>0&&
    {JSON.parse(additional.notes_json).map((note:string)=>
  • {note}
  • )}
}
} +
+ +
+ {editing ? ( + + ) : !readOnlyMode && ( + Edit + )} +
+ +
+
+
+
+ +
+
+
+ {editing ? ( + + ) : ( +

{recipe.title}

+ )} +
+
+ + {editing && ( +
+
+ + {saved &&
Changes saved.
} + {error &&
{error}
} +
+ Total Yield +
+ + +
+ + +
+ + + +
+
+ + + Auto calculate total yield +
+
+ )} + + {editing ? ( +
+ +
+ ) : ( +
+
+ +
+
+ )} +
+ +
+
+ + + + + +
+ +
+
+ {editing ? ( +
+
+
+

Additional details

+
+
+ Shelf Life + + + +
+ + +
+
+
+ ) : ( +
+

Prep Method {domainRecipe.steps.length}

+
    + {domainRecipe.steps.map(step=>
  1. {step.order}.{step.instruction}
  2. )} +
+
+ )} + +
+

U of M Equivalency

+

XX Weight = XX Volume = XX Each

+

+ If you would like to use this recipe by weight, volume, and even by the portion - you can customize that here. + +

+ +
+ +
+ Standard Weight - Volume Conversion + When toggled on, conversions are locked to 8oz = 1 cup. Toggle off to customize. +
+
+ +
+ +
+ Weight +
+ + +
+
+ + +
=
+ + +
+ Volume +
+ + +
+
+ + +
=
+ + +
+ Each +
+ + +
+
+
+ + {recipeConversions.length > 0 && ( +
+

Custom Equivalencies

+ {recipeConversions.map(x => ( +
+ {x.from_quantity} {unitMap.get(x.from_unit_id)?.symbol ?? x.from_unit_id} + = + {x.to_quantity} {unitMap.get(x.to_unit_id)?.symbol ?? x.to_unit_id} + {x.notes && {x.notes}} +
+ ))} +
+ )} +
+ +
+ +
+ +
+ +
+
+
+ + {!editing && ( +
+
+

Additional details

+ {additional.station&&

Station{additional.station}

} + {shelfLife&&

Shelf life{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}

} + {JSON.parse(additional.notes_json??"[]").length>0&&
    {JSON.parse(additional.notes_json).map((note:string)=>
  • {note}
  • )}
} +
+
+ )} +
- {!editing&&} + {editing&&} diff --git a/src/components/DetailUtility.astro b/src/components/DetailUtility.astro index 3b3cbce..9a9bad2 100644 --- a/src/components/DetailUtility.astro +++ b/src/components/DetailUtility.astro @@ -20,9 +20,43 @@ const resolvedHref=sectionHref??inferred.href;
  • Home
  • {resolvedSection&&
  • {resolvedSection}
  • } +
    -
    - {!readOnlyMode&&
    New
    } + + {!readOnlyMode&&
    + New + +
    }
    + diff --git a/src/components/EntityDirectory.tsx b/src/components/EntityDirectory.tsx index 73362ae..0fcdcfd 100644 --- a/src/components/EntityDirectory.tsx +++ b/src/components/EntityDirectory.tsx @@ -1,9 +1,21 @@ import { useRef,useState } from "preact/hooks"; export type DirectoryRow = { - id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; icon:string; + id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; detail?:string; }; -type Props={ rows:DirectoryRow[]; entityType:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean }; +const TYPE_ICONS:Record = { + recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z", + ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z", + book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z", + purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z" +}; +const TYPE_ICON_TRANSFORMS:Record = { + recipe:"scale(1.1, 1.1) translate(4px, 3.5px)", + ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)", + book:"scale(1.1, 1.1) translate(3.5px, 2.5px)", + purchase:undefined +}; +type Props={ rows:DirectoryRow[]; entityType?:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean }; export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=false}:Props) { const [selected,setSelected]=useState([]),[deleting,setDeleting]=useState(false),[error,setError]=useState(""); @@ -12,34 +24,59 @@ export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=f const allSelected=rows.length>0&&selected.length===rows.length; const toggle=(id:string)=>setSelected(current=>current.includes(id)?current.filter(value=>value!==id):[...current,id]); const requestDelete=(ids:string[])=>{if(!ids.length)return;setPendingDelete(ids);dialog.current?.showModal();}; + const kindById=new Map(rows.map(row=>[row.id,row.kind])); const remove=async()=>{ const ids=pendingDelete; if(!ids.length)return; setDeleting(true);setError(""); - const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:entityType,ids})}); - const result=await response.json(); - if(!response.ok){setError(result.error??"Unable to delete selection.");setDeleting(false);dialog.current?.close();return;} + const groups=new Map(); + for(const id of ids){const kind=kindById.get(id)??entityType??"recipe";if(!groups.has(kind))groups.set(kind,[]);groups.get(kind)!.push(id);} + try{ + for(const [kind,groupIds] of groups){ + const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:kind,ids:groupIds})}); + const result=await response.json(); + if(!response.ok){setError(result.error??"Unable to delete selection.");dialog.current?.close();return;} + } + } finally { setDeleting(false); } location.reload(); }; return
    - {!readOnly&&
    - {if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/> - Type - Name -
    {selected.length>0&&<>{selected.length} selected}
    + {!readOnly&&
    0?" has-selection":""}`}> + {if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/> + {selected.length>0 ? ( +
    + {selected.length} Selected +
    + + +
    +
    + ) : ( + <> + Type + Name +
    + + )}
    } {error&&

    {error}

    }
    {rows.map(row=>
    {!readOnly&&toggle(row.id)}/>} - {row.icon} - {row.href?{row.name}:{row.name}} - {!readOnly&&
    } + + {row.href?{row.name}{row.detail&&{row.detail}}:<>{row.name}{row.detail&&{row.detail}}} + {!readOnly&&
    }
    )}
    {rows.length===0&&
    {emptyMessage}
    } {!readOnly&&{if(!deleting)setPendingDelete([]);}}>

    Delete {pendingDelete.length===1?"item":`${pendingDelete.length} items`}?

    -

    This permanently removes the selected {pendingDelete.length===1?entityType:`${entityType} items`}. This action cannot be undone.

    +

    This permanently removes the selected {pendingDelete.length===1?(entityType??"item"):`${pendingDelete.length} ${entityType??"items"}`}. This action cannot be undone.

    }
    ; diff --git a/src/components/RecipeCalculator.tsx b/src/components/RecipeCalculator.tsx index 0207a4f..f4be7bb 100644 --- a/src/components/RecipeCalculator.tsx +++ b/src/components/RecipeCalculator.tsx @@ -49,7 +49,7 @@ function convertItem(quantity: number, fromUnitId: string, toUnitId: string, ite export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [] }: Props) { const [factor, setFactor] = useState(1); - const [calculatePercent,setCalculatePercent]=useState(true); + const [calculatePercent,setCalculatePercent]=useState(showPercentControls); const [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard"); const [yieldDisplayUnitId, setYieldDisplayUnitId] = useState(yieldUnitId); const [lineUnits, setLineUnits] = useState>({}); @@ -78,29 +78,52 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield return (
    -
    -

    Scalable formula

    -

    Ingredients

    -
    -
    -

    1× produces {number(yieldQuantity)} {units[yieldUnitId]?.symbol??yieldUnitId} finished yield. Changing the multiplier, finished yield, or any ingredient amount scales the entire recipe.

    {calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&
    Base
    {baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return

    {const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/>{item.label}

    })}
    } {components.map((component) => ( @@ -108,7 +131,7 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield {components.length > 1 &&

    {component.name}

    }
    - {calculatePercent&&} + {calculatePercent&&} {component.items.filter(item=>percentMode!=="bakers"||!calculatePercent||!item.basisMember).map((item) => { const displayUnitId = lineUnits[item.id] ?? item.amount.unit_id; @@ -120,13 +143,13 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield }; return ( + - {calculatePercent&&} - + {calculatePercent&&} ); })} @@ -176,7 +199,7 @@ function CostLedgerLine({ line, factor, currency, editable, expanded }: { line:C const body=<> {line.kind==="recipe"?"R":"●"}{line.name} - {line.completeness<1&&!} + {line.completeness<1&&} {line.cost!=null?money.format(line.cost*factor):line.kind==="ingredient"&&editable?Add cost  ✎:"—"} ; if(line.purchase||line.children?.length)return
    {body}
    {line.purchase?<>
    Purchase item name{line.purchase.name}
    Purchase cost{money.format(line.purchase.price)}
    Purchase unit{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}
    Date added{line.purchase.effectiveAt}
    Item ID #{line.purchase.sku??"—"}
    Vendor{line.purchase.supplier??"—"}
    :

    No usable purchase cost is available.

    }{line.children?.length?
    {line.children.map(child=>)}
    :null}
    ; diff --git a/src/components/RecipeStructureEditor.tsx b/src/components/RecipeStructureEditor.tsx index bb54258..5dd3e39 100644 --- a/src/components/RecipeStructureEditor.tsx +++ b/src/components/RecipeStructureEditor.tsx @@ -1,4 +1,6 @@ import { useEffect, useState } from "preact/hooks"; +import { Fragment } from "preact"; +import { createPortal } from "preact/compat"; import type { RecipeStructure } from "../lib/database"; import { percentage, targetWeight } from "../lib/percentages"; @@ -40,6 +42,11 @@ export default function RecipeStructureEditor({ const [prepText, setPrepText] = useState(""); const [prepError, setPrepError] = useState(""); const [pendingIngredients, setPendingIngredients] = useState([]); + const [methodTarget, setMethodTarget] = useState(null); + + useEffect(() => { + setMethodTarget(document.getElementById("recipe-method-editor-slot")); + }, []); const [baseline, setBaseline] = useState(JSON.stringify(initial)); const [state, setState] = useState<"idle" | "saving" | "saved" | "error">( "idle", @@ -448,70 +455,98 @@ export default function RecipeStructureEditor({ {autoYield && unconvertedYieldItems() > 0 && (

    Auto yield excludes {unconvertedYieldItems()} ingredient {unconvertedYieldItems() === 1 ? "amount" : "amounts"} without a weight equivalency.

    )} - {data.components.map((component, componentIndex) => ( -
    event.preventDefault()} onDrop={() => { - if (dragging?.kind === "component") setData((current) => ({ ...current, components: move(current.components, dragging.index, componentIndex) })); - setDragging(undefined); - }}> - Ingredient header {componentIndex + 1} -
    - - updateComponent(componentIndex, (value) => ({ - ...value, - name: event.currentTarget.value, - })) - } - /> - - setDragging({ kind: "component", index: componentIndex })}>⠿ -
    - {(component.notes ?? []).map((note, noteIndex) => ( -
    - updateComponent(componentIndex, (value) => ({ - ...value, - notes: (value.notes ?? []).map((entry, index) => index === noteIndex ? event.currentTarget.value : entry), - }))} - /> - -
    - ))} -
    -
    Ingredient{percentMode==="standard"?"Standard %":"Baker's %"}Weight
    AmountIngredient{percentMode==="standard"?"Standard %":"Baker's %"}
    changeLineQuantity(Number(event.currentTarget.value))}/> - {item.href ? {item.label} : item.label}{item.attention&&!} + {item.href ? {item.label} : item.label}{item.attention&&} {item.optional && optional} {item.notes && {item.notes}} {itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`} changeLineQuantity(Number(event.currentTarget.value))}/>{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}
    - - - {calculatePercent && percentMode === "bakers" && ( - - )} - {calculatePercent && } - - - - - - - - +
    +
    Base%QtyUnitIngredient / RecipeNotes
    + + + + + + + {calculatePercent && percentMode === "bakers" && ( + + )} + {calculatePercent && } + + + + + {data.components.map((component, componentIndex) => ( + + {(data.components.length > 1 || Boolean(component.name?.trim())) && ( + event.preventDefault()} + onDrop={() => { + if (dragging?.kind === "component") setData((current) => ({ ...current, components: move(current.components, dragging.index, componentIndex) })); + setDragging(undefined); + }} + > + + + )} + {(component.notes ?? []).map((note, noteIndex) => ( + + + + ))} {component.items.map((item, itemIndex) => { const isPending = Boolean( item.ingredient_id && @@ -534,16 +569,121 @@ export default function RecipeStructureEditor({ const shownChoices = choices.filter((entry) => !typedValue.trim() || [entry.name, ...(entry.aliases ?? [])].some((name) => normal(name).includes(normal(typedValue)))).slice(0, 10); const hasUncommittedInput = normal(typedValue) !== normal(label ?? ""); const canCreateInput = Boolean(typedValue.trim()) && !exactMatch; + const isSubrecipe = Boolean(item.subrecipe_id); return ( - event.preventDefault()} onDrop={(event) => { + event.preventDefault()} onDrop={(event) => { event.stopPropagation(); if (dragging?.kind === "item" && dragging.component === componentIndex) updateComponent(componentIndex, (value) => ({ ...value, items: move(value.items, dragging.index, itemIndex) })); setDragging(undefined); }}> + + + + {calculatePercent && percentMode === "bakers" && ( )} - - - - ); })} - -
    QtyUnitIngredient / RecipeNotesBase%
    +
    + + updateComponent(componentIndex, (value) => ({ + ...value, + name: event.currentTarget.value, + })) + } + /> +
    + + setDragging({ kind: "component", index: componentIndex })} + > + + +
    +
    +
    +
    + updateComponent(componentIndex, (value) => ({ + ...value, + notes: (value.notes ?? []).map((entry, index) => index === noteIndex ? event.currentTarget.value : entry), + }))} + /> + +
    +
    + + updateComponent(componentIndex, (value) => ({ + ...value, + items: value.items.map((line, index) => + index === itemIndex + ? { + ...line, + quantity: Number( + event.currentTarget.value, + ), + } + : line, + ), + })) + } + /> + + + +
    + { setFocusedItem(item.id); event.currentTarget.select(); }} + onBlur={() => window.setTimeout(() => setFocusedItem((current) => current === item.id ? undefined : current), 120)} + onInput={(event) => setRowQuery((current) => ({ ...current, [item.id]: event.currentTarget.value }))} + /> + {isPending && ( + + + + )} + {focusedItem === item.id && ( +
    + {shownChoices.map((entry) => ( + + ))} + {canCreateInput && ( +
    + +
    + )} +
    + )} +
    + {isPending && New ingredient · saved automatically} +
    + + updateComponent(componentIndex, (value) => ({ + ...value, + items: value.items.map((line, index) => + index === itemIndex + ? { ...line, notes: event.currentTarget.value } + : line, + ), + })) + } + /> + @@ -592,106 +732,13 @@ export default function RecipeStructureEditor({ )} - - updateComponent(componentIndex, (value) => ({ - ...value, - items: value.items.map((line, index) => - index === itemIndex - ? { - ...line, - quantity: Number( - event.currentTarget.value, - ), - } - : line, - ), - })) - } - /> - - - -
    - { setFocusedItem(item.id); event.currentTarget.select(); }} - onBlur={() => window.setTimeout(() => setFocusedItem((current) => current === item.id ? undefined : current), 120)} - onInput={(event) => setRowQuery((current) => ({ ...current, [item.id]: event.currentTarget.value }))} - /> - {isPending && !} - {focusedItem === item.id && ( -
    - {shownChoices.map((entry) => ( - - ))} - {canCreateInput && ( -
    - - -
    - )} -
    - )} -
    - {isPending && New ingredient · created on save} -
    - - updateComponent(componentIndex, (value) => ({ - ...value, - items: value.items.map((line, index) => - index === itemIndex - ? { ...line, notes: event.currentTarget.value } - : line, - ), - })) - } - /> - - +
    - { event.stopPropagation(); setDragging({ kind: "item", component: componentIndex, index: itemIndex }); }}>⠿ - + { + event.stopPropagation(); + setDragging({ + kind: "item", + component: componentIndex, + index: itemIndex, + }); + }} + > + + +
    -
    -
    - +
    +
    + Enter to complete + Tab to add a note +
    +
    + + - + }) + } + > + + Add Note + + +
    {bulkOpen && ( )} {showMethod && ( -
    -
    -

    Prep Method {data.steps.length}

    -
      - {data.steps.map((step, index) => { - const stepKind = step.instruction.trim().endsWith(":") ? " prep-heading" : /^\(.+\)$/.test(step.instruction.trim()) ? " prep-note" : ""; - return ( -
    1. event.preventDefault()} onDrop={() => { - if (dragging?.kind === "step") setData((current) => ({ ...current, steps: move(current.steps, dragging.index, index) })); - setDragging(undefined); - }}> -