refactor: simplify application and consolidate schema #15
No files matched your search
+574
-108260
File diff suppressed because it is too large.
Load diff
@@ -1,90 +1,8 @@
|
||||
# Formulation API & Model Context Protocol (MCP) Guide
|
||||
# Formulation REST API Guide
|
||||
|
||||
Formulation exposes two programmatic interfaces for querying, scaling, costing, and manipulating culinary data:
|
||||
1. **Model Context Protocol (MCP) Server**: A standard stdio JSON-RPC server enabling AI assistants (Claude Desktop, Antigravity, Cursor, Gemini) to directly search, retrieve, cost, scale, and save recipes, ingredients, books, and inventory counts.
|
||||
2. **REST Web API (`/api/v1/`)**: HTTP JSON endpoints for external systems, webhooks, and programmatic integrations.
|
||||
Formulation exposes HTTP JSON endpoints for external systems, webhooks, and programmatic integrations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Model Context Protocol (MCP) Server
|
||||
|
||||
### Starting the Server
|
||||
```bash
|
||||
npm run mcp
|
||||
# or directly:
|
||||
node scripts/mcp-server.mjs
|
||||
```
|
||||
|
||||
### Client Configuration Examples
|
||||
|
||||
#### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json` on Windows / `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"formulation": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"C:\\Users\\nicholas\\Documents\\repos\\formulation\\scripts\\mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Antigravity / Gemini (`.gemini/config/mcp_config.json`)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"formulation": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"scripts/mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Cursor (`.cursor/mcp.json`)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"formulation": {
|
||||
"command": "node",
|
||||
"args": ["scripts/mcp-server.mjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MCP Tools Reference (15 Domain Tools)
|
||||
|
||||
| Tool | Parameters | Description |
|
||||
| --- | --- | --- |
|
||||
| `search_recipes` | `query?`, `category?`, `tag?`, `limit?` | Search recipes by keyword, category, or tag |
|
||||
| `get_recipe` | `id`, `scale_factor?`, `target_yield?`, `target_yield_unit?` | Retrieve full recipe formulation with optional scaling |
|
||||
| `save_recipe` | `id?`, `title`, `yield_quantity`, `yield_unit_id`, `components`, `steps`, `notes?` | Create or update a recipe formulation with validation |
|
||||
| `delete_recipe` | `id` | Safely archive a recipe from the active library |
|
||||
| `calculate_recipe_cost` | `recipe_id`, `currency?` | Compute itemized ingredient & sub-recipe cost breakdown |
|
||||
| `calculate_recipe_nutrition` | `recipe_id`, `serving_size_g?` | Compute nutrition facts panel (macros/micros) |
|
||||
| `search_ingredients` | `query?`, `category?`, `missing_cost?`, `limit?` | Search ingredients in the pantry catalog |
|
||||
| `get_ingredient` | `id` | Get ingredient detail with density, conversions, and prices |
|
||||
| `save_ingredient` | `id?`, `name`, `description?`, `categories?`, `tags?`, `aliases?`, `density?` | Create or update an ingredient with density & aliases |
|
||||
| `delete_ingredient` | `id` | Safely archive an ingredient (blocks if used in active recipes) |
|
||||
| `convert_units` | `ingredient_id?`, `quantity`, `from_unit`, `to_unit` | Convert culinary units safely using density data |
|
||||
| `list_inventory_counts` | `status?` (`'all' \| 'open' \| 'completed'`) | List inventory counting sessions |
|
||||
| `get_inventory_count` | `id` | Get full count sheet with locations, items, unit costs, and valuations |
|
||||
| `list_recipe_books` | *(none)* | List recipe books (collections) with recipe counts |
|
||||
| `get_recipe_book` | `id` | Get recipe book details with ordered included recipes |
|
||||
| `save_recipe_book` | `id?`, `name`, `description?`, `recipe_ids?` | Create or update a recipe book / collection |
|
||||
| `export_database_backup` | *(none)* | Export complete JSON backup of all 25 SQLite tables |
|
||||
| `get_database_stats` | *(none)* | Get table entity counts across the database |
|
||||
|
||||
---
|
||||
|
||||
## 2. REST Web API Specification (`/api/v1/`)
|
||||
## REST Web API Specification (`/api/v1/`)
|
||||
|
||||
All REST endpoints return standard JSON envelopes:
|
||||
- Success: `{ "success": true, "data": ... }`
|
||||
@@ -0,0 +1,157 @@
|
||||
# meez Recipe Edit UI Audit
|
||||
|
||||
Observed in the signed-in meez web application on August 20, 2026. The audit was read-only: edit mode was opened, temporary text was entered into quick add to inspect transient states, and the page was reloaded without saving.
|
||||
|
||||
## Scope
|
||||
|
||||
- Recipe view-to-edit transition
|
||||
- Formula and prep-method layout
|
||||
- Ingredient rows, section headings, notes, and quick add
|
||||
- Primary actions, tabs, validation, drag affordances, and supporting details
|
||||
- Desktop viewport observed at approximately 1041 × 669 CSS pixels
|
||||
|
||||
## Overall composition
|
||||
|
||||
- The workspace is a fixed two-pane desktop layout split close to 50/50.
|
||||
- The left pane contains recipe identity, yield controls, percentage mode, and the ingredient formula.
|
||||
- The right pane contains persistent recipe tabs and the active detail editor. In Prep Method, the cover image leads into steps and additional details.
|
||||
- A slim vertical divider separates the panes. Both sides read as one continuous white document rather than cards inside a dashboard.
|
||||
- A 48px global utility bar remains at the top. Entering edit mode changes the outlined `Edit` action into a green `Done` action without navigating away.
|
||||
|
||||
## Visual language
|
||||
|
||||
### Core tokens observed
|
||||
|
||||
| Token | Observed value | Usage |
|
||||
| --- | --- | --- |
|
||||
| Primary ink | `rgb(5, 8, 65)` / `#050841` | Titles, body text, outlines |
|
||||
| Action blue | `rgb(61, 93, 246)` / `#3d5df6` | New button, active/focus accents, links |
|
||||
| Save green | `rgb(32, 171, 133)` / `#20ab85` | Done/save state |
|
||||
| Muted gray | approximately `#a5a9c1` | Icons, placeholders, secondary labels |
|
||||
| Canvas | `rgb(243, 243, 243)` | Application background outside editor surfaces |
|
||||
| Surface | white | Both editor panes and input surfaces |
|
||||
| Error | muted red/pink | Invalid ingredient cells and alert icons |
|
||||
| Typeface | `CircularCustCapNum, sans-serif` | Entire application UI |
|
||||
|
||||
Body typography is 16px, weight 300, with a 20px line height. The visual hierarchy comes from weight and spacing more than from large changes in size.
|
||||
|
||||
### Type hierarchy
|
||||
|
||||
- Recipe name: large, very bold, navy, edited inline with no boxed-input appearance.
|
||||
- Pane title (`Prep Method`): 22px, weight 700, line height 33px.
|
||||
- Formula/prep headings: 18px, weight 500, line height 27px.
|
||||
- Ingredient and quick-add text: 15px, weight 300.
|
||||
- Prep step text: 16px, weight 400, line height 25.6px.
|
||||
- Supporting labels and table headers are smaller and muted.
|
||||
|
||||
## View-to-edit behavior
|
||||
|
||||
- View mode is clean and typographic. Ingredients appear as quantity/unit plus linked ingredient name and trailing note.
|
||||
- Clicking `Edit` changes the same page in place; it does not replace the layout with a separate form.
|
||||
- In edit mode:
|
||||
- The recipe name becomes an inline textbox.
|
||||
- Yield becomes quantity and searchable unit controls.
|
||||
- Auto-calculate yield and percentage controls appear.
|
||||
- Ingredient rows expose quantity, unit, ingredient/recipe, note, remove, and drag controls.
|
||||
- Prep content becomes textareas in place.
|
||||
- `Edit` becomes `Done`, using green as the commit signal.
|
||||
- The right-side recipe tabs remain available while editing.
|
||||
|
||||
## Formula editor
|
||||
|
||||
### Column structure
|
||||
|
||||
- Columns are `%`, `Qty`, `Unit`, and `Ingredient / Recipe`.
|
||||
- Notes are displayed on a second line spanning most of the ingredient row rather than as a narrow permanent column.
|
||||
- Rows use very light separators and white space. There are no rounded cards.
|
||||
- Empty quantity/unit/reference cells show understated placeholders.
|
||||
|
||||
### Section headings
|
||||
|
||||
- Headings occupy a full-width row.
|
||||
- Heading text is 18px/500 and left aligned.
|
||||
- Remove and six-dot drag controls sit at the far right.
|
||||
- The controls are visually quiet; the heading text carries the structure.
|
||||
|
||||
### Ingredient notes
|
||||
|
||||
- Notes are multiline textareas, even when they contain only one line.
|
||||
- They sit beneath the main ingredient fields, use 15px light text, and have no visible box at rest.
|
||||
- Observed textarea styling: transparent background, no border, approximately 27px line height, and light internal padding.
|
||||
- Empty notes appear as italic/muted `Add Notes` prompts.
|
||||
|
||||
### Validation and incomplete rows
|
||||
|
||||
- Invalid or unresolved ingredient references tint the reference cell very pale pink.
|
||||
- A thin red underline and a compact circular alert icon identify the exact failing field.
|
||||
- Other cells remain neutral, keeping error scope local.
|
||||
- Incomplete rows can remain in the editor while the user continues working.
|
||||
|
||||
### Reordering and deletion
|
||||
|
||||
- Section and ingredient rows expose a six-dot vertical drag handle.
|
||||
- A circular minus icon is used for removal.
|
||||
- These controls sit in a narrow action rail at the right edge of the formula pane.
|
||||
|
||||
## Quick add
|
||||
|
||||
- Quick add is a two-field row at the bottom of the formula:
|
||||
- Ingredient parser/search: approximately 302 × 55px in the observed viewport.
|
||||
- Note field: approximately 141 × 54px.
|
||||
- The ingredient field has a subtle gray shadow; the note field is visually flatter.
|
||||
- Both use 15px, weight 300 text with 16px × 10px padding.
|
||||
- The parser accepts natural compact input such as `1cup onion sliced`.
|
||||
- Keyboard guidance sits directly below the fields:
|
||||
- `Enter to complete`
|
||||
- `Tab to add a note`
|
||||
- When text is present, a prompt appears immediately above the field: `Done? Press Enter to add this ingredient to the recipe`.
|
||||
- Autocomplete opens upward because the control is at the bottom of the pane.
|
||||
- Search results are compact list rows with an icon and truncated label. Different icon treatments distinguish catalog/source types.
|
||||
- The focused field receives a pale blue background/focus treatment rather than a thick rounded outline.
|
||||
- No-results feedback is announced separately while the typed value remains intact.
|
||||
|
||||
## Add actions
|
||||
|
||||
- `Add Header` and `Add Note` are blue text actions with leading plus icons.
|
||||
- `Add Ingredients` is the stronger secondary action: transparent fill, navy 1px outline, 100px pill radius, 16px/500 text, 12px × 16px padding, approximately 40px tall.
|
||||
- The hierarchy is deliberate: frequent lightweight structural additions are links; bulk import is a bordered button.
|
||||
|
||||
## Prep Method editor
|
||||
|
||||
- Prep Method remains in the right pane and is edited in place.
|
||||
- The pane title includes a muted step count.
|
||||
- Prep headings use the same full-width structural pattern as formula headings, including remove and drag controls.
|
||||
- Steps are numbered in a narrow leading column. The instruction itself is a borderless multiline textarea.
|
||||
- Observed prep textarea typography is 16px/400 with a 25.6px line height and 16px horizontal padding.
|
||||
- Media upload appears below eligible steps as a quiet drag-and-drop region with file constraints.
|
||||
- A blank next step is always available at the end, reducing the need for a separate single-step add action.
|
||||
- `Add Header`, `Add Note`, and `Add Prep Steps` mirror the formula footer action hierarchy.
|
||||
|
||||
## Additional details
|
||||
|
||||
- Shelf life, station, tags, and owner follow the prep method in the same continuous pane.
|
||||
- Shelf life uses paired quantity and unit fields.
|
||||
- Station and tags are searchable comboboxes.
|
||||
- Sections are separated by whitespace and thin rules, not enclosing cards.
|
||||
|
||||
## Parity priorities for Formulation
|
||||
|
||||
1. Preserve the two-pane information architecture, while keeping explicit structured controls for quantities, units, references, percentages, and validation.
|
||||
2. Make notes second-line, borderless textareas under ingredient rows; do not treat notes as a permanent narrow table column.
|
||||
3. Match the quick-add geometry, upward autocomplete, focus treatment, prompt placement, and keyboard sequence.
|
||||
4. Use inline field-scoped validation: pale error background, thin red underline, and compact alert icon.
|
||||
5. Unify formula and prep structural actions: heading, note, bulk add, drag, and remove should use the same interaction grammar.
|
||||
6. Keep edit mode in place and make `Done` the single clear green commit action.
|
||||
7. Use document-like editing selectively for headings, notes, and prep instructions. Reduce their card chrome while revealing clear boundaries on hover, focus, error, or incomplete state.
|
||||
8. Keep a blank prep step available and allow multiline content without explicit textarea chrome.
|
||||
|
||||
## Follow-up audits
|
||||
|
||||
- Add Header and Add Note focus, commit, blur, empty-value, and deletion behavior
|
||||
- Exact Enter/Tab selection behavior with existing ingredient, existing recipe, new ingredient, and new recipe
|
||||
- Bulk ingredient and bulk prep dialogs
|
||||
- Drag-and-drop feedback and keyboard accessibility
|
||||
- Done/save validation, conflict, loading, and failure states
|
||||
- Standard vs. Baker's percentage behavior
|
||||
- Auto-yield recalculation states
|
||||
- Responsive/tablet behavior
|
||||
@@ -125,7 +125,8 @@ CREATE TABLE purchase_items (
|
||||
status TEXT NOT NULL, package_quantity REAL NOT NULL,
|
||||
package_unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
units_per_case INTEGER NOT NULL DEFAULT 1,
|
||||
usable_yield_factor REAL NOT NULL DEFAULT 1
|
||||
usable_yield_factor REAL NOT NULL DEFAULT 1,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE price_observations (
|
||||
@@ -161,6 +162,41 @@ CREATE TABLE recipe_media (
|
||||
FOREIGN KEY (recipe_id, step_id) REFERENCES recipe_steps(recipe_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE inventory_locations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE inventory_counts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
counted_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE inventory_count_items (
|
||||
count_id TEXT NOT NULL REFERENCES inventory_counts(id) ON DELETE CASCADE,
|
||||
location_id TEXT REFERENCES inventory_locations(id),
|
||||
ingredient_id TEXT NOT NULL REFERENCES ingredients(id),
|
||||
quantity REAL NOT NULL,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
unit_cost REAL,
|
||||
extended_cost REAL,
|
||||
PRIMARY KEY (count_id, location_id, ingredient_id)
|
||||
);
|
||||
|
||||
INSERT INTO inventory_locations (id, name, position, deleted_at) VALUES
|
||||
('loc_walk_in', 'Walk-in Cooler', 1, NULL),
|
||||
('loc_dry_storage', 'Dry Storage', 2, NULL),
|
||||
('loc_freezer', 'Freezer', 3, NULL),
|
||||
('loc_bar', 'Bar & Service', 4, NULL),
|
||||
('loc_line', 'Prep Line', 5, NULL);
|
||||
|
||||
CREATE INDEX recipe_items_ingredient_idx ON recipe_items(ingredient_id);
|
||||
CREATE INDEX recipe_items_subrecipe_idx ON recipe_items(subrecipe_id);
|
||||
CREATE INDEX purchase_items_ingredient_idx ON purchase_items(ingredient_id);
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_locations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_counts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
counted_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_count_items (
|
||||
count_id TEXT NOT NULL REFERENCES inventory_counts(id) ON DELETE CASCADE,
|
||||
location_id TEXT REFERENCES inventory_locations(id),
|
||||
ingredient_id TEXT NOT NULL REFERENCES ingredients(id),
|
||||
quantity REAL NOT NULL,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
unit_cost REAL,
|
||||
extended_cost REAL,
|
||||
PRIMARY KEY (count_id, location_id, ingredient_id)
|
||||
);
|
||||
|
||||
-- Seed baseline standard locations if table is empty
|
||||
INSERT OR IGNORE INTO inventory_locations (id, name, position, deleted_at) VALUES
|
||||
('loc_walk_in', 'Walk-in Cooler', 1, NULL),
|
||||
('loc_dry_storage', 'Dry Storage', 2, NULL),
|
||||
('loc_freezer', 'Freezer', 3, NULL),
|
||||
('loc_bar', 'Bar & Service', 4, NULL),
|
||||
('loc_line', 'Prep Line', 5, NULL);
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Soft delete parity for purchase items
|
||||
ALTER TABLE purchase_items ADD COLUMN deleted_at TEXT;
|
||||
Generated
+7
-907
File diff suppressed because it is too large.
Load diff
+1
-2
@@ -23,12 +23,11 @@
|
||||
"db:restore": "node scripts/backup.mjs import",
|
||||
"db:reset": "node scripts/backup.mjs import data/recipe-book-backup.json",
|
||||
"db:validate": "node scripts/backup.mjs validate",
|
||||
"mcp": "node scripts/mcp-server.mjs"
|
||||
"db:migrate-recipe-ids": "node scripts/migrate-recipe-ids.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.1",
|
||||
"@astrojs/preact": "6.0.2",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"astro": "7.2.1",
|
||||
"preact": "10.29.8",
|
||||
"yaml": "2.9.0",
|
||||
|
||||
@@ -8,7 +8,6 @@ const root = path.resolve(import.meta.dirname, "..");
|
||||
const databasePath = path.join(root, "var", "recipe-book.sqlite");
|
||||
const defaultSeedPath = path.join(root, "data", "recipe-book-backup.json");
|
||||
|
||||
// Dynamically import compiled or source backup engine
|
||||
import { exportDatabase } from "../src/lib/backup/export-database.ts";
|
||||
import { importDatabase } from "../src/lib/backup/import-database.ts";
|
||||
import { validateBackupBundle } from "../src/lib/backup/validate-backup.ts";
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Advanced Docling Recipe Ingestor for "The Pastry Chef's Little Black Book, Vol. I"
|
||||
*
|
||||
* Supports:
|
||||
* - 2-page facing spreads (Table on Left, Procedure on Right)
|
||||
* - Multi-component formulation splitting (Dough Packet, Butter Packet, Filling, Crust)
|
||||
* - Fractional spoon & unit fallbacks across all columns
|
||||
* - Multi-stage procedure preservation with equipment inference
|
||||
* - Shelf-life & chef's notes extraction
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/ingest-docling-book.mjs --dry-run --pages 26-27
|
||||
* node scripts/ingest-docling-book.mjs --save
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
@@ -22,9 +8,6 @@ const root = path.resolve(import.meta.dirname, "..");
|
||||
const databasePath = path.join(root, "var", "recipe-book.sqlite");
|
||||
const doclingPath = path.join(root, "file.json");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table of Contents Chapter Ranges
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHAPTER_PAGE_RANGES = [
|
||||
{ name: "Doughs", startPage: 11, endPage: 38, category: "doughs" },
|
||||
{ name: "Tart, Pie & Strudel Fillings", startPage: 39, endPage: 64, category: "tart_pie_fillings" },
|
||||
@@ -48,9 +31,6 @@ export function getChapterForPage(pageNo) {
|
||||
return { name: "General Pastry", category: "pastry" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extended Ingredient Normalization Map
|
||||
// ---------------------------------------------------------------------------
|
||||
const KNOWN_INGREDIENT_MAP = {
|
||||
"butter": "butter",
|
||||
"unsalted butter": "butter",
|
||||
@@ -196,7 +176,7 @@ function slugify(text) {
|
||||
function parseMetricAmount(str) {
|
||||
if (!str) return null;
|
||||
const s = str.trim().toLowerCase();
|
||||
|
||||
|
||||
const kgMatch = s.match(/^([\d.,]+)\s*kg$/i);
|
||||
if (kgMatch) {
|
||||
return { quantity: Math.round(parseFloat(kgMatch[1].replace(/,/g, "")) * 1000 * 100) / 100, unit_id: "gram" };
|
||||
@@ -251,7 +231,7 @@ function parseUsFallback(text, name) {
|
||||
if (s.includes("2 t")) return { quantity: 10, unit_id: "gram", notes: "2 tsp" };
|
||||
if (s.includes("1 t") && !s.includes("tbsp")) return { quantity: 5, unit_id: "gram", notes: "1 tsp" };
|
||||
if (s.includes("tbsp") || s.includes("1 t") || s.includes("2 t")) return { quantity: 15, unit_id: "gram", notes: "1 Tbsp" };
|
||||
|
||||
|
||||
const eachMatch = s.match(/^([\d.]+)\s*(?:each|pc|ea)?$/);
|
||||
if (eachMatch && parseFloat(eachMatch[1]) > 0) return { quantity: parseFloat(eachMatch[1]), unit_id: "each", notes: null };
|
||||
|
||||
@@ -352,9 +332,6 @@ function parseShelfLife(notesList) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page & Recipe Extractor
|
||||
// ---------------------------------------------------------------------------
|
||||
export function parseDoclingBook(doclingJson, targetPages = null) {
|
||||
const pagesMap = new Map();
|
||||
|
||||
@@ -406,7 +383,6 @@ export function parseDoclingBook(doclingJson, targetPages = null) {
|
||||
const isIngredientTable = headerRow && [...headerRow.values()].some((v) => /ingredients/i.test(v));
|
||||
if (!isIngredientTable) continue;
|
||||
|
||||
// Find Recipe Title on this page
|
||||
const titleNode = page.texts.find(
|
||||
(t) => t.label === "section_header" && !/procedure|chef's notes|notes|table of contents|scaling|baking/i.test(t.text)
|
||||
);
|
||||
@@ -420,7 +396,6 @@ export function parseDoclingBook(doclingJson, targetPages = null) {
|
||||
usedSlugs.set(slugId, 1);
|
||||
}
|
||||
|
||||
// Parse ingredients into components
|
||||
const components = [];
|
||||
let currentComponent = { id: "main", name: "Main", items: [] };
|
||||
components.push(currentComponent);
|
||||
@@ -443,7 +418,6 @@ export function parseDoclingBook(doclingJson, targetPages = null) {
|
||||
|
||||
if (!ingText) continue;
|
||||
|
||||
// Detect sub-component headers inside tables like "Dough Packet (Détrempe):" or "Filling:"
|
||||
if (ingText.endsWith(":") && !metricText && !usText) {
|
||||
const compName = ingText.replace(/:$/, "").trim();
|
||||
const compSlug = slugify(compName);
|
||||
@@ -490,11 +464,9 @@ export function parseDoclingBook(doclingJson, targetPages = null) {
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out empty components
|
||||
const validComponents = components.filter((c) => c.items.length > 0);
|
||||
if (validComponents.length === 0) continue;
|
||||
|
||||
// Calculate Baker's Percentages across all components
|
||||
const allItems = validComponents.flatMap((c) => c.items);
|
||||
const flourBasisWeight = allItems
|
||||
.filter((i) => i.basis_member)
|
||||
@@ -523,7 +495,6 @@ export function parseDoclingBook(doclingJson, targetPages = null) {
|
||||
}),
|
||||
}));
|
||||
|
||||
// Find procedure steps on current page or facing spread page (pageNo + 1)
|
||||
const textSources = [...page.texts];
|
||||
const nextPage = pagesMap.get(pageNo + 1);
|
||||
if (nextPage && nextPage.tables.length === 0) {
|
||||
@@ -569,7 +540,7 @@ function cleanProcedureHeader(raw) {
|
||||
|
||||
for (const textNode of textSources) {
|
||||
const text = textNode.text.trim();
|
||||
|
||||
|
||||
if (/^yields?:\s*/i.test(text)) {
|
||||
yieldDescription = text;
|
||||
continue;
|
||||
@@ -656,9 +627,6 @@ function cleanProcedureHeader(raw) {
|
||||
return recipes;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch Ingestion Runner
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const isSave = args.includes("--save");
|
||||
@@ -684,7 +652,6 @@ async function main() {
|
||||
const recipes = parseDoclingBook(doc, targetPages);
|
||||
console.log(`\nFound ${recipes.length} formulation(s).`);
|
||||
|
||||
// Detailed inspect for targeted page runs
|
||||
if (targetPages && targetPages.length <= 5) {
|
||||
for (const recipe of recipes) {
|
||||
console.log(`\n================================================================`);
|
||||
@@ -727,10 +694,10 @@ async function main() {
|
||||
|
||||
if (isSave) {
|
||||
console.log(`\n💾 Ingesting ${recipes.length} recipes into Formulation database...`);
|
||||
const { createMcpTools } = await import("../src/mcp/tools.ts");
|
||||
const { createDomainTools } = await import("../src/lib/domain-tools.ts");
|
||||
const { openDatabase, refreshSiteProjection } = await import("../src/lib/database.ts");
|
||||
const db = openDatabase({ readOnly: false });
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
|
||||
try {
|
||||
let newIngredientsCount = 0;
|
||||
@@ -761,7 +728,6 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update or create Master Collection
|
||||
if (!targetPages) {
|
||||
const collectionId = "the_pastry_chefs_little_black_book_vol_1";
|
||||
tools.saveRecipeBook({
|
||||
|
||||
@@ -42,19 +42,15 @@ function standardizeInstruction(text) {
|
||||
let clean = text.trim();
|
||||
if (!clean) return clean;
|
||||
|
||||
// Check if it's an inline note: (Note: ...) or (...)
|
||||
if (clean.startsWith("(") && clean.endsWith(")")) {
|
||||
return clean;
|
||||
}
|
||||
|
||||
// Check if it's a section heading: ends with colon
|
||||
if (clean.endsWith(":")) {
|
||||
// Capitalize first character
|
||||
clean = clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||
return clean;
|
||||
}
|
||||
|
||||
// Check for common gerund starts
|
||||
const words = clean.split(/\s+/);
|
||||
const firstWordLower = words[0].toLowerCase();
|
||||
if (gerundMap[firstWordLower]) {
|
||||
@@ -62,13 +58,10 @@ function standardizeInstruction(text) {
|
||||
clean = words.join(" ");
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
clean = clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||
|
||||
// Replace trailing comma, semicolon, or dash with period
|
||||
clean = clean.replace(/[,;\-\s]+$/, "");
|
||||
|
||||
// Ensure terminal punctuation if not ending with : or )
|
||||
if (!clean.endsWith(".") && !clean.endsWith("!") && !clean.endsWith("?") && !clean.endsWith(":") && !clean.endsWith(")")) {
|
||||
clean += ".";
|
||||
}
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Formulation MCP Server — standalone stdio entry point.
|
||||
*
|
||||
* This script opens the SQLite database directly (bypassing Astro runtime)
|
||||
* and starts the Model Context Protocol server on stdin/stdout.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/mcp-server.mjs
|
||||
* npm run mcp
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { createSiteProjection } from "./lib/site-projection.mjs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database helpers (standalone, no Astro dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const databasePath = path.join(root, "var", "recipe-book.sqlite");
|
||||
|
||||
function openDb(readOnly = true) {
|
||||
if (!fs.existsSync(databasePath)) throw new Error(`Database not found at ${databasePath}`);
|
||||
const db = new DatabaseSync(databasePath, { readOnly });
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
return db;
|
||||
}
|
||||
|
||||
function loadCatalogs() {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
const projection = createSiteProjection(db);
|
||||
const map = (values) => new Map(values.map((v) => [v.id, v]));
|
||||
return {
|
||||
ingredients: map(projection.ingredients),
|
||||
recipes: map(projection.recipes),
|
||||
units: map(projection.units),
|
||||
equipment: map(projection.equipment),
|
||||
prepActions: map(projection.prepActions),
|
||||
purchaseItems: map(projection.purchaseItems),
|
||||
sourceMappings: map(projection.sourceMappings),
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic imports for domain modules (TypeScript, processed by Node's ESM)
|
||||
// ---------------------------------------------------------------------------
|
||||
const { calculateCost } = await import("../src/lib/costing.ts");
|
||||
const { calculateNutrition } = await import("../src/lib/nutrition.ts");
|
||||
const { convert, convertWithIngredientMeasures } = await import("../src/lib/measurement.ts");
|
||||
const { exportDatabase } = await import("../src/lib/backup/export-database.ts");
|
||||
const { titleCase } = await import("../src/lib/format.ts");
|
||||
const { getInventoryCountDetail, getInventoryCounts } = await import("../src/lib/repository/inventory-repository.ts");
|
||||
const { recipeQualityRows, restoreArchivedItems } = await import("../src/lib/database.ts");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
function searchRecipes(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const q = (args.query ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
let results = [...catalogs.recipes.values()];
|
||||
|
||||
if (q) results = results.filter((r) => r.title.toLowerCase().includes(q) || r.id.toLowerCase().includes(q) || (r.summary ?? "").toLowerCase().includes(q));
|
||||
if (args.category) results = results.filter((r) => r.categories.includes(args.category));
|
||||
if (args.tag) results = results.filter((r) => (r.tags ?? []).includes(args.tag));
|
||||
|
||||
return results.slice(0, limit).map((r) => ({
|
||||
id: r.id, title: r.title, summary: r.summary ?? null,
|
||||
categories: r.categories, tags: r.tags,
|
||||
yield: { quantity: r.yield.amount.quantity, unit_id: r.yield.amount.unit_id, servings: r.yield.servings ?? null },
|
||||
component_count: r.components.length,
|
||||
item_count: r.components.reduce((n, c) => n + c.items.length, 0),
|
||||
step_count: r.steps.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function getRecipe(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.id}`);
|
||||
|
||||
let sf = 1;
|
||||
if (args.scale_factor > 0) sf = args.scale_factor;
|
||||
else if (args.target_yield > 0 && recipe.yield.amount.quantity > 0) {
|
||||
if (args.target_yield_unit && args.target_yield_unit !== recipe.yield.amount.unit_id) {
|
||||
try { sf = convert(args.target_yield, args.target_yield_unit, recipe.yield.amount.unit_id, catalogs.units) / recipe.yield.amount.quantity; } catch { sf = args.target_yield / recipe.yield.amount.quantity; }
|
||||
} else sf = args.target_yield / recipe.yield.amount.quantity;
|
||||
}
|
||||
|
||||
return {
|
||||
id: recipe.id, title: recipe.title, summary: recipe.summary ?? null,
|
||||
categories: recipe.categories, tags: recipe.tags, station: recipe.station ?? null,
|
||||
yield: { quantity: recipe.yield.amount.quantity * sf, base_quantity: recipe.yield.amount.quantity, unit_id: recipe.yield.amount.unit_id, servings: recipe.yield.servings ? recipe.yield.servings * sf : null, basis: recipe.yield.basis ?? null },
|
||||
scale_factor: sf, scaling: recipe.scaling ?? null,
|
||||
components: recipe.components.map((c) => ({
|
||||
id: c.id, name: c.name, notes: c.notes ?? [],
|
||||
items: c.items.map((item) => {
|
||||
const ref = item.reference;
|
||||
const isSub = "recipe_id" in ref;
|
||||
const sid = isSub ? ref.recipe_id : ref.ingredient_id;
|
||||
const subject = isSub ? catalogs.recipes.get(sid) : catalogs.ingredients.get(sid);
|
||||
return {
|
||||
id: item.id,
|
||||
ingredient_id: isSub ? undefined : sid,
|
||||
subrecipe_id: isSub ? sid : undefined,
|
||||
name: subject ? (isSub ? subject.title : titleCase(subject.name)) : sid,
|
||||
is_subrecipe: isSub,
|
||||
quantity: item.amount.quantity * sf,
|
||||
base_quantity: item.amount.quantity,
|
||||
unit_id: item.amount.unit_id,
|
||||
percentage: item.percentage ?? null,
|
||||
basis_member: item.basis_member ?? false,
|
||||
optional: item.optional ?? false,
|
||||
notes: item.notes ?? null,
|
||||
prep: item.prep ?? [],
|
||||
};
|
||||
}),
|
||||
})),
|
||||
steps: recipe.steps.map((s, i) => ({ id: s.id, order: i + 1, instruction: s.instruction, critical_control_point: s.critical_control_point ?? false, equipment_ids: s.equipment_ids ?? [] })),
|
||||
equipment_ids: recipe.equipment_ids ?? [],
|
||||
notes: recipe.notes ?? [],
|
||||
shelf_life: recipe.shelf_life ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function calcCost(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.recipe_id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
|
||||
return calculateCost(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, purchaseItems: catalogs.purchaseItems, prepActions: catalogs.prepActions }, args.currency ?? "USD");
|
||||
}
|
||||
|
||||
function calcNutrition(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.recipe_id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
|
||||
return calculateNutrition(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, mappings: catalogs.sourceMappings });
|
||||
}
|
||||
|
||||
function searchIngredients(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const q = (args.query ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
let results = [...catalogs.ingredients.values()];
|
||||
|
||||
if (q) results = results.filter((ing) => ing.name.toLowerCase().includes(q) || ing.id.toLowerCase().includes(q) || (ing.aliases ?? []).some((a) => a.name.toLowerCase().includes(q)));
|
||||
if (args.category) results = results.filter((ing) => ing.categories.includes(args.category));
|
||||
if (args.missing_cost) {
|
||||
const priced = new Set([...catalogs.purchaseItems.values()].filter((pi) => pi.status === "active").map((pi) => pi.ingredient_id));
|
||||
results = results.filter((ing) => !priced.has(ing.id));
|
||||
}
|
||||
|
||||
return results.slice(0, limit).map((ing) => ({
|
||||
id: ing.id, name: titleCase(ing.name), status: ing.status, categories: ing.categories,
|
||||
alias_count: (ing.aliases ?? []).length,
|
||||
has_nutrition: (ing.nutrition_mapping_ids ?? []).length > 0,
|
||||
has_cost: [...catalogs.purchaseItems.values()].some((pi) => pi.ingredient_id === ing.id && pi.status === "active"),
|
||||
}));
|
||||
}
|
||||
|
||||
function getIngredientDetail(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const ing = catalogs.ingredients.get(args.id);
|
||||
if (!ing) throw new Error(`Ingredient not found: ${args.id}`);
|
||||
const purchases = [...catalogs.purchaseItems.values()].filter((pi) => pi.ingredient_id === args.id);
|
||||
return {
|
||||
id: ing.id, name: titleCase(ing.name), raw_name: ing.name, status: ing.status,
|
||||
categories: ing.categories, tags: ing.tags ?? [], aliases: ing.aliases ?? [],
|
||||
density_measurements: (ing.density_measurements ?? []).map((d) => ({ id: d.id, mass: d.mass, volume: d.volume, state: d.state ?? null })),
|
||||
measure_conversions: (ing.measure_conversions ?? []).map((c) => ({ id: c.id, from: c.from, to: c.to, state: c.state ?? null })),
|
||||
prep_actions: ing.prep_actions ?? [],
|
||||
nutrition_mapping_ids: ing.nutrition_mapping_ids ?? [],
|
||||
purchase_items: purchases.map((p) => ({ id: p.id, name: p.name, brand: p.brand ?? null, status: p.status, package_quantity: p.package.quantity, package_unit_id: p.package.unit_id, latest_price: p.prices.length > 0 ? p.prices[p.prices.length - 1].amount : null, currency: p.prices.length > 0 ? p.prices[p.prices.length - 1].currency : null })),
|
||||
};
|
||||
}
|
||||
|
||||
function convertUnits(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const units = catalogs.units;
|
||||
const fromUnit = units.get(args.from_unit);
|
||||
const toUnit = units.get(args.to_unit);
|
||||
if (!fromUnit) throw new Error(`Unknown unit: ${args.from_unit}`);
|
||||
if (!toUnit) throw new Error(`Unknown unit: ${args.to_unit}`);
|
||||
|
||||
let result, method;
|
||||
if (fromUnit.dimension === toUnit.dimension) {
|
||||
result = convert(args.quantity, args.from_unit, args.to_unit, units);
|
||||
method = "dimension_conversion";
|
||||
} else if (args.ingredient_id) {
|
||||
const ing = catalogs.ingredients.get(args.ingredient_id);
|
||||
if (!ing) throw new Error(`Unknown ingredient: ${args.ingredient_id}`);
|
||||
const converted = convertWithIngredientMeasures({ quantity: args.quantity, unit_id: args.from_unit }, args.to_unit, ing, units);
|
||||
result = converted.quantity;
|
||||
method = "ingredient_measure_conversion";
|
||||
} else {
|
||||
throw new Error(`Cannot convert ${fromUnit.dimension} to ${toUnit.dimension} without an ingredient_id for density lookup.`);
|
||||
}
|
||||
return { from: { quantity: args.quantity, unit_id: args.from_unit }, to: { quantity: result, unit_id: args.to_unit }, ingredient_id: args.ingredient_id ?? null, method };
|
||||
}
|
||||
|
||||
function listUnits(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
let units = [...catalogs.units.values()];
|
||||
if (args?.dimension) units = units.filter((u) => u.dimension === args.dimension);
|
||||
if (args?.system) units = units.filter((u) => u.system === args.system);
|
||||
return units;
|
||||
}
|
||||
|
||||
function listEquipment(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
let items = [...catalogs.equipment.values()];
|
||||
if (args?.category) items = items.filter((e) => e.category === args.category);
|
||||
return items;
|
||||
}
|
||||
|
||||
function listPrepActions() {
|
||||
const catalogs = loadCatalogs();
|
||||
return [...catalogs.prepActions.values()];
|
||||
}
|
||||
|
||||
function listPurchaseItems(args) {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
let query = `
|
||||
SELECT p.id, p.ingredient_id, i.name as ingredient_name, p.name, p.brand,
|
||||
p.supplier_id, p.supplier_sku, p.status, p.package_quantity, p.package_unit_id,
|
||||
p.units_per_case, p.usable_yield_factor
|
||||
FROM purchase_items p
|
||||
JOIN ingredients i ON i.id = p.ingredient_id
|
||||
WHERE p.deleted_at IS NULL
|
||||
`;
|
||||
const params = [];
|
||||
if (args.ingredient_id) { query += " AND p.ingredient_id = ?"; params.push(args.ingredient_id); }
|
||||
if (args.status) { query += " AND p.status = ?"; params.push(args.status); }
|
||||
query += " ORDER BY p.name LIMIT ?";
|
||||
params.push(Math.min(Math.max(args.limit ?? 50, 1), 200));
|
||||
|
||||
const rows = db.prepare(query).all(...params);
|
||||
const priceStmt = db.prepare("SELECT amount, currency, effective_at FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC LIMIT 1");
|
||||
return rows.map((r) => {
|
||||
const p = priceStmt.get(r.id);
|
||||
return { ...r, ingredient_name: titleCase(r.ingredient_name), latest_price: p?.amount ?? null, currency: p?.currency ?? null };
|
||||
});
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
function getDatabaseStats() {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
const c = (t) => db.prepare(`SELECT count(*) as c FROM ${t}`).get().c;
|
||||
return { recipes: c("recipes"), ingredients: c("ingredients"), units: c("units"), equipment: c("equipment"), purchase_items: c("purchase_items"), collections: c("collections"), inventory_counts: c("inventory_counts") };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
function listRecipeBooks() {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
return db.prepare(
|
||||
`SELECT c.id, c.name, c.description, COUNT(cr.recipe_id) as recipe_count
|
||||
FROM collections c LEFT JOIN collection_recipes cr ON cr.collection_id = c.id
|
||||
WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.name`
|
||||
).all();
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
function getRecipeBook(args) {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
const book = db.prepare("SELECT id, name, description FROM collections WHERE id = ? AND deleted_at IS NULL").get(args.id);
|
||||
if (!book) throw new Error(`Recipe book not found: ${args.id}`);
|
||||
const recipes = db.prepare(
|
||||
`SELECT r.id, r.title, r.summary, cr.position
|
||||
FROM collection_recipes cr JOIN recipes r ON r.id = cr.recipe_id
|
||||
WHERE cr.collection_id = ? AND r.deleted_at IS NULL ORDER BY cr.position, r.title`
|
||||
).all(args.id);
|
||||
return { ...book, recipes };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
function auditQuality() {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
const rows = recipeQualityRows(db);
|
||||
const clean = rows.filter((r) => r.placeholder_steps === 0 && r.unpriced_items === 0 && r.yield_basis !== null).length;
|
||||
return { summary: { total: rows.length, clean, needs_attention: rows.length - clean }, recipes: rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP Server setup
|
||||
// ---------------------------------------------------------------------------
|
||||
const server = new McpServer({ name: "formulation", version: "2.0.0" });
|
||||
|
||||
function wrap(fn) {
|
||||
return async (args) => {
|
||||
try {
|
||||
const result = fn(args);
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
server.tool("search_recipes", "Search recipes by keyword, category, or tag.", { query: z.string().optional(), category: z.string().optional(), tag: z.string().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchRecipes));
|
||||
server.tool("get_recipe", "Get full recipe formulation with optional scaling.", { id: z.string(), scale_factor: z.number().positive().optional(), target_yield: z.number().positive().optional(), target_yield_unit: z.string().optional() }, wrap(getRecipe));
|
||||
server.tool("audit_recipe_quality", "Run automated quality audit on all recipes.", {}, wrap(auditQuality));
|
||||
server.tool("calculate_recipe_cost", "Compute itemized cost breakdown for a recipe.", { recipe_id: z.string(), currency: z.string().default("USD") }, wrap(calcCost));
|
||||
server.tool("calculate_recipe_nutrition", "Calculate nutrition facts per 100g and per serving.", { recipe_id: z.string(), serving_size_g: z.number().positive().optional() }, wrap(calcNutrition));
|
||||
server.tool("search_ingredients", "Search ingredients with cost/nutrition status.", { query: z.string().optional(), category: z.string().optional(), missing_cost: z.boolean().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchIngredients));
|
||||
server.tool("get_ingredient", "Get ingredient detail with density, equivalencies, and prices.", { id: z.string() }, wrap(getIngredientDetail));
|
||||
server.tool("list_purchase_items", "List purchase items with current prices.", { ingredient_id: z.string().optional(), status: z.string().optional(), limit: z.number().int().default(50) }, wrap(listPurchaseItems));
|
||||
server.tool("convert_units", "Convert culinary units using ingredient density data.", { ingredient_id: z.string().optional(), quantity: z.number().positive(), from_unit: z.string(), to_unit: z.string() }, wrap(convertUnits));
|
||||
server.tool("list_units", "List measurement units with conversion factors.", { dimension: z.string().optional(), system: z.string().optional() }, wrap(listUnits));
|
||||
server.tool("list_equipment", "List kitchen equipment.", { category: z.string().optional() }, wrap(listEquipment));
|
||||
server.tool("list_prep_actions", "List culinary prep actions.", {}, wrap(listPrepActions));
|
||||
server.tool("list_inventory_counts", "List inventory counting sessions.", { status: z.enum(["all", "open", "completed"]).default("all") }, wrap((args) => { const db = openDb(true); try { let counts = getInventoryCounts(db); if (args.status !== "all") counts = counts.filter((c) => c.status === args.status); return counts; } finally { db.close(); } }));
|
||||
server.tool("get_inventory_count", "Get full inventory count sheet with items and valuations.", { id: z.string() }, wrap((args) => { const db = openDb(true); try { const d = getInventoryCountDetail(db, args.id); if (!d) throw new Error(`Count not found: ${args.id}`); return d; } finally { db.close(); } }));
|
||||
server.tool("list_recipe_books", "List recipe books with recipe counts.", {}, wrap(listRecipeBooks));
|
||||
server.tool("get_recipe_book", "Get recipe book detail with included recipes.", { id: z.string() }, wrap(getRecipeBook));
|
||||
server.tool("export_database_backup", "Export complete JSON backup of all 25 tables.", {}, wrap(() => { const db = openDb(true); try { return exportDatabase(db); } finally { db.close(); } }));
|
||||
server.tool("get_database_stats", "Get entity count statistics.", {}, wrap(getDatabaseStats));
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Formulation MCP Server running on stdio");
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { writeSiteProjection } from "./lib/site-projection.mjs";
|
||||
|
||||
const alphabet = "0123456789abcdefghjkmnpqrstvwxyz";
|
||||
const recipeIdPattern = /^[0-9a-hjkmnp-tv-z]{26}$/;
|
||||
|
||||
function encode(value, length) {
|
||||
let output = "";
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
output = alphabet[value % 32] + output;
|
||||
value = Math.floor(value / 32);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function newRecipeId() {
|
||||
const bytes = randomBytes(16);
|
||||
let suffix = "";
|
||||
for (const byte of bytes) suffix += alphabet[byte & 31];
|
||||
return `${encode(Date.now(), 10)}${suffix}`;
|
||||
}
|
||||
|
||||
function replaceIds(value, ids) {
|
||||
if (typeof value === "string") return ids.get(value) ?? value;
|
||||
if (Array.isArray(value)) return value.map((entry) => replaceIds(entry, ids));
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, replaceIds(entry, ids)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function migrateJsonColumn(database, table, keyColumn, jsonColumn, ids) {
|
||||
const rows = database.prepare(`SELECT ${keyColumn} AS row_key, ${jsonColumn} AS json_value FROM ${table}`).all();
|
||||
const update = database.prepare(`UPDATE ${table} SET ${jsonColumn}=? WHERE ${keyColumn}=?`);
|
||||
for (const row of rows) {
|
||||
if (!row.json_value) continue;
|
||||
try {
|
||||
const current = JSON.parse(row.json_value);
|
||||
const migrated = replaceIds(current, ids);
|
||||
update.run(JSON.stringify(migrated), row.row_key);
|
||||
} catch {
|
||||
// Non-JSON source text is left untouched.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const database = new DatabaseSync(path.join(root, "var", "recipe-book.sqlite"));
|
||||
const rows = database.prepare("SELECT id FROM recipes ORDER BY id").all();
|
||||
const existing = new Set(rows.map((row) => row.id));
|
||||
const ids = new Map();
|
||||
|
||||
for (const { id } of rows) {
|
||||
if (recipeIdPattern.test(id)) continue;
|
||||
let replacement = newRecipeId();
|
||||
while (existing.has(replacement)) replacement = newRecipeId();
|
||||
existing.add(replacement);
|
||||
ids.set(id, replacement);
|
||||
}
|
||||
|
||||
if (!ids.size) {
|
||||
console.log("Recipe IDs are already immutable; nothing to migrate.");
|
||||
database.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
database.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
||||
try {
|
||||
const recipeIdTables = [
|
||||
"recipe_components",
|
||||
"recipe_items",
|
||||
"item_prep_actions",
|
||||
"recipe_steps",
|
||||
"recipe_equipment",
|
||||
"step_equipment",
|
||||
"recipe_measure_conversions",
|
||||
"collection_recipes",
|
||||
"recipe_media",
|
||||
];
|
||||
for (const [oldId, newId] of ids) {
|
||||
for (const table of recipeIdTables) database.prepare(`UPDATE ${table} SET recipe_id=? WHERE recipe_id=?`).run(newId, oldId);
|
||||
database.prepare("UPDATE recipe_items SET subrecipe_id=? WHERE subrecipe_id=?").run(newId, oldId);
|
||||
database.prepare("UPDATE source_mappings SET subject_id=? WHERE subject_type='recipe' AND subject_id=?").run(newId, oldId);
|
||||
database.prepare("UPDATE recipes SET id=? WHERE id=?").run(newId, oldId);
|
||||
}
|
||||
migrateJsonColumn(database, "recipes", "id", "source_json", ids);
|
||||
migrateJsonColumn(database, "collections", "id", "source_json", ids);
|
||||
migrateJsonColumn(database, "source_mappings", "id", "source_json", ids);
|
||||
database.exec("COMMIT; PRAGMA foreign_keys=ON");
|
||||
const violations = database.prepare("PRAGMA foreign_key_check").all();
|
||||
if (violations.length) throw new Error(`Foreign-key validation failed: ${JSON.stringify(violations)}`);
|
||||
writeSiteProjection(database);
|
||||
console.log(`Migrated ${ids.size} recipe IDs to immutable identifiers.`);
|
||||
} catch (error) {
|
||||
try { database.exec("ROLLBACK"); } catch {}
|
||||
database.exec("PRAGMA foreign_keys=ON");
|
||||
throw error;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { writeSiteProjection } from "./lib/site-projection.mjs";
|
||||
import { exportDatabase } from "../src/lib/backup/export-database.ts";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const databasePath = path.join(root, "var", "recipe-book.sqlite");
|
||||
const backupPath = path.join(root, "data", "recipe-book-backup.json");
|
||||
|
||||
const db = new DatabaseSync(databasePath, { readOnly: false });
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
try {
|
||||
const book = db.prepare("SELECT * FROM collections WHERE id = ?").get("the_pastry_chefs_little_black_book_vol_1");
|
||||
if (!book) {
|
||||
console.log("Collection the_pastry_chefs_little_black_book_vol_1 not found.");
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`Found collection: "${book.name}" (${book.id})`);
|
||||
|
||||
const bookRecipeRows = db.prepare("SELECT recipe_id FROM collection_recipes WHERE collection_id = ?").all(book.id);
|
||||
const bookRecipeIds = new Set(bookRecipeRows.map(r => r.recipe_id));
|
||||
console.log(`Recipes to remove: ${bookRecipeIds.size}`);
|
||||
|
||||
const allRecipeItems = db.prepare("SELECT recipe_id, ingredient_id, subrecipe_id FROM recipe_items").all();
|
||||
const externalDependents = allRecipeItems.filter(
|
||||
item => !bookRecipeIds.has(item.recipe_id) && item.subrecipe_id && bookRecipeIds.has(item.subrecipe_id)
|
||||
);
|
||||
if (externalDependents.length > 0) {
|
||||
throw new Error(`Cannot delete recipes: ${externalDependents.length} external items depend on them: ${JSON.stringify(externalDependents)}`);
|
||||
}
|
||||
|
||||
const nonBookIngredientIds = new Set(
|
||||
allRecipeItems.filter(item => !bookRecipeIds.has(item.recipe_id) && item.ingredient_id).map(item => item.ingredient_id)
|
||||
);
|
||||
|
||||
const stubIngredients = db.prepare("SELECT id, name, categories_json FROM ingredients WHERE categories_json LIKE '%imported_stub%'").all();
|
||||
const ingredientsToDelete = stubIngredients.filter(ing => !nonBookIngredientIds.has(ing.id));
|
||||
const ingredientsToKeepAndClean = stubIngredients.filter(ing => nonBookIngredientIds.has(ing.id));
|
||||
|
||||
console.log(`Stub ingredients to delete: ${ingredientsToDelete.length}`);
|
||||
console.log(`Stub ingredients used elsewhere (keeping and removing 'imported_stub' category): ${ingredientsToKeepAndClean.length} (${ingredientsToKeepAndClean.map(i => i.id).join(", ")})`);
|
||||
|
||||
db.exec("BEGIN TRANSACTION");
|
||||
|
||||
for (const ing of ingredientsToKeepAndClean) {
|
||||
const cats = JSON.parse(ing.categories_json ?? "[]").filter(c => c !== "imported_stub");
|
||||
db.prepare("UPDATE ingredients SET categories_json = ? WHERE id = ?").run(JSON.stringify(cats), ing.id);
|
||||
}
|
||||
|
||||
db.prepare("DELETE FROM collection_recipes WHERE collection_id = ?").run(book.id);
|
||||
db.prepare("DELETE FROM collections WHERE id = ?").run(book.id);
|
||||
|
||||
const deleteRecipeStmt = db.prepare("DELETE FROM recipes WHERE id = ?");
|
||||
for (const recipeId of bookRecipeIds) {
|
||||
deleteRecipeStmt.run(recipeId);
|
||||
}
|
||||
|
||||
const deleteIngredientStmt = db.prepare("DELETE FROM ingredients WHERE id = ?");
|
||||
for (const ing of ingredientsToDelete) {
|
||||
deleteIngredientStmt.run(ing.id);
|
||||
}
|
||||
|
||||
db.exec("COMMIT");
|
||||
console.log("Database transaction committed successfully.");
|
||||
|
||||
writeSiteProjection(db);
|
||||
console.log("Site projection updated.");
|
||||
|
||||
const backupBundle = exportDatabase(db);
|
||||
fs.writeFileSync(backupPath, JSON.stringify(backupBundle, null, 2) + "\n", "utf8");
|
||||
console.log(`Exported updated backup to ${backupPath}`);
|
||||
|
||||
const recipeCount = db.prepare("SELECT count(*) as count FROM recipes WHERE deleted_at IS NULL").get();
|
||||
const ingredientCount = db.prepare("SELECT count(*) as count FROM ingredients WHERE deleted_at IS NULL").get();
|
||||
const collectionCount = db.prepare("SELECT count(*) as count FROM collections WHERE deleted_at IS NULL").get();
|
||||
|
||||
console.log("\n--- Current Database Totals ---");
|
||||
console.log(`Active Recipes: ${recipeCount.count}`);
|
||||
console.log(`Active Ingredients: ${ingredientCount.count}`);
|
||||
console.log(`Active Collections: ${collectionCount.count}`);
|
||||
|
||||
} catch (err) {
|
||||
try { db.exec("ROLLBACK"); } catch {}
|
||||
console.error("Error during removal:", err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { parseIngredientsWithOllama } from "../../../../../lib/ingredient-parser";
|
||||
import { parseIngredientsDeterministic } from "../../../../../lib/ingredient-parser";
|
||||
import { readOnlyMode } from "../../../../../lib/runtime";
|
||||
|
||||
export const prerender = false;
|
||||
@@ -10,7 +10,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
const body = await request.json() as { text?: unknown };
|
||||
if (typeof body.text !== "string") return Response.json({ error: "Ingredient text is required." }, { status: 400 });
|
||||
if (body.text.length > 20_000) return Response.json({ error: "Ingredient text is too long." }, { status: 413 });
|
||||
return Response.json(await parseIngredientsWithOllama(body.text));
|
||||
return Response.json(parseIngredientsDeterministic(body.text));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.name === "TimeoutError"
|
||||
? "Ingredient parser timed out. Try again."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -9,14 +10,11 @@ export const GET: APIRoute = async () => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const archived = tools.listArchivedItems();
|
||||
return Response.json({ success: true, data: archived });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list archived items." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list archived items.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -17,14 +18,11 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.restoreArchived({ items: body.items });
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to restore items." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to restore items.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -12,15 +13,12 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const book = tools.getRecipeBook({ id });
|
||||
return Response.json({ success: true, data: book });
|
||||
} catch (error) {
|
||||
const status = error instanceof Error && error.message.includes("not found") ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get recipe book." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Failed to get recipe book.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -36,14 +34,11 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
body.id = id;
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.saveRecipeBook(body);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update recipe book." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to update recipe book.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -63,10 +58,7 @@ export const DELETE: APIRoute = async ({ params }) => {
|
||||
database.prepare("UPDATE collections SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||
return Response.json({ success: true, message: `Recipe book '${id}' archived.` });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to archive recipe book." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to archive recipe book.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -9,14 +10,11 @@ export const GET: APIRoute = async () => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const books = tools.listRecipeBooks();
|
||||
return Response.json({ success: true, count: books.length, data: books });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list recipe books." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list recipe books.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -32,14 +30,11 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
return Response.json({ success: false, error: "Recipe book name is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.saveRecipeBook(body);
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create recipe book." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to create recipe book.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../mcp/tools";
|
||||
import { apiError } from "../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -17,7 +18,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.convertUnits({
|
||||
ingredient_id: body.ingredient_id,
|
||||
quantity: Number(body.quantity),
|
||||
@@ -26,10 +27,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
});
|
||||
return Response.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Conversion failed." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Conversion failed.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -10,16 +11,13 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const equipment = tools.listEquipment({
|
||||
category: url.searchParams.get("category") ?? undefined,
|
||||
});
|
||||
return Response.json({ success: true, count: equipment.length, data: equipment });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list equipment." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list equipment.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -12,15 +13,12 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const ingredient = tools.getIngredient({ id });
|
||||
return Response.json({ success: true, data: ingredient });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get ingredient." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Failed to get ingredient.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -36,14 +34,11 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
body.id = id;
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.saveIngredient(body);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update ingredient." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to update ingredient.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -57,15 +52,12 @@ export const DELETE: APIRoute = async ({ params }) => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.deleteIngredient({ id });
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
const status = error instanceof Error && error.message.includes("Cannot archive") ? 409 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to delete ingredient." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Failed to delete ingredient.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -10,7 +11,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const ingredients = tools.searchIngredients({
|
||||
query: url.searchParams.get("q") ?? url.searchParams.get("query") ?? undefined,
|
||||
category: url.searchParams.get("category") ?? undefined,
|
||||
@@ -19,10 +20,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
});
|
||||
return Response.json({ success: true, count: ingredients.length, data: ingredients });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list ingredients." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list ingredients.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -38,14 +36,11 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
return Response.json({ success: false, error: "Ingredient name is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.saveIngredient(body);
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create ingredient." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to create ingredient.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -13,14 +14,11 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
const statusParam = url.searchParams.get("status");
|
||||
const status = statusParam === "open" || statusParam === "completed" ? statusParam : "all";
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const counts = tools.listInventoryCounts({ status });
|
||||
return Response.json({ success: true, count: counts.length, data: counts });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list inventory counts." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list inventory counts.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -39,7 +37,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.createInventoryCountSession({
|
||||
title: body.title,
|
||||
counted_at: body.counted_at,
|
||||
@@ -48,10 +46,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
});
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create inventory count." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to create inventory count.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -12,15 +13,12 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const count = tools.getInventoryCount({ id });
|
||||
return Response.json({ success: true, data: count });
|
||||
} catch (error) {
|
||||
const status = error instanceof Error && error.message.includes("not found") ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get inventory count." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Failed to get inventory count.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -35,7 +33,7 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.updateInventoryCount({
|
||||
count_id: id,
|
||||
status: body.status,
|
||||
@@ -44,10 +42,7 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
||||
});
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update inventory count." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to update inventory count.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -67,10 +62,7 @@ export const DELETE: APIRoute = async ({ params }) => {
|
||||
database.prepare("UPDATE inventory_counts SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||
return Response.json({ success: true, message: `Inventory count '${id}' archived.` });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to archive inventory count." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to archive inventory count.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -9,14 +10,11 @@ export const GET: APIRoute = async () => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const actions = tools.listPrepActions();
|
||||
return Response.json({ success: true, count: actions.length, data: actions });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list prep actions." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list prep actions.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -12,15 +13,12 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const item = tools.getPurchaseItem({ id });
|
||||
return Response.json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
const status = error instanceof Error && error.message.includes("not found") ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get purchase item." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Failed to get purchase item.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -36,14 +34,11 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
body.id = id;
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.savePurchaseItem(body);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update purchase item." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to update purchase item.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -63,10 +58,7 @@ export const DELETE: APIRoute = async ({ params }) => {
|
||||
database.prepare("UPDATE purchase_items SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||
return Response.json({ success: true, message: `Purchase item '${id}' archived.` });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to archive purchase item." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to archive purchase item.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -20,7 +21,7 @@ export const POST: APIRoute = async ({ params, request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.recordPriceObservation({
|
||||
purchase_item_id: id,
|
||||
amount: body.amount,
|
||||
@@ -29,10 +30,7 @@ export const POST: APIRoute = async ({ params, request }) => {
|
||||
});
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to record price observation." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to record price observation.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -10,7 +11,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const items = tools.listPurchaseItems({
|
||||
ingredient_id: url.searchParams.get("ingredient_id") ?? undefined,
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
@@ -18,10 +19,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
});
|
||||
return Response.json({ success: true, count: items.length, data: items });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list purchase items." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list purchase items.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -40,14 +38,11 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.savePurchaseItem(body);
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create purchase item." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to create purchase item.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -14,15 +15,12 @@ export const GET: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const currency = url.searchParams.get("currency") ?? "USD";
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const cost = tools.calculateRecipeCost({ recipe_id: id, currency });
|
||||
return Response.json({ success: true, data: cost });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Cost calculation failed." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Cost calculation failed.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -13,7 +14,7 @@ export const GET: APIRoute = async ({ params, request }) => {
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const recipe = tools.getRecipe({
|
||||
id,
|
||||
scale_factor: url.searchParams.get("scale") ? Number(url.searchParams.get("scale")) : undefined,
|
||||
@@ -23,10 +24,7 @@ export const GET: APIRoute = async ({ params, request }) => {
|
||||
return Response.json({ success: true, data: recipe });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get recipe." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Failed to get recipe.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -42,14 +40,11 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
body.id = id;
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.saveRecipe(body);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update recipe." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to update recipe.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -69,10 +64,7 @@ export const DELETE: APIRoute = async ({ params }) => {
|
||||
database.prepare("UPDATE recipes SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||
return Response.json({ success: true, message: `Recipe '${id}' archived.` });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to archive recipe." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to archive recipe.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -12,15 +13,12 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const nutrition = tools.calculateRecipeNutrition({ recipe_id: id });
|
||||
return Response.json({ success: true, data: nutrition });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Nutrition calculation failed." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Nutrition calculation failed.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -13,7 +14,7 @@ export const POST: APIRoute = async ({ params, request }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const recipe = tools.getRecipe({
|
||||
id,
|
||||
scale_factor: body.scale_factor,
|
||||
@@ -23,10 +24,7 @@ export const POST: APIRoute = async ({ params, request }) => {
|
||||
return Response.json({ success: true, data: recipe });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Scaling failed." },
|
||||
{ status }
|
||||
);
|
||||
return apiError(error, "Scaling failed.", status);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -12,7 +13,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const recipes = tools.searchRecipes({
|
||||
query: url.searchParams.get("q") ?? url.searchParams.get("query") ?? undefined,
|
||||
category: url.searchParams.get("category") ?? undefined,
|
||||
@@ -22,10 +23,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
return Response.json({ success: true, count: recipes.length, data: recipes });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list recipes." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list recipes.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -39,14 +37,11 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const result = tools.saveRecipe(body);
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create recipe." },
|
||||
{ status: 400 }
|
||||
);
|
||||
return apiError(error, "Failed to create recipe.", 400);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -9,14 +10,11 @@ export const GET: APIRoute = async () => {
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const audit = tools.auditRecipeQuality();
|
||||
return Response.json({ success: true, data: audit });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to run quality audit." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to run quality audit.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
import { apiError } from "../../../../../lib/api-response";
|
||||
import { createDomainTools } from "../../../../../lib/domain-tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -10,17 +11,14 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const tools = createDomainTools(() => database);
|
||||
const units = tools.listUnits({
|
||||
dimension: url.searchParams.get("dimension") ?? undefined,
|
||||
system: url.searchParams.get("system") ?? undefined,
|
||||
});
|
||||
return Response.json({ success: true, count: units.length, data: units });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list units." },
|
||||
{ status: 500 }
|
||||
);
|
||||
return apiError(error, "Failed to list units.", 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ function formatDeleteDate(dateStr: string | null) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Batch Actions Floating Bar -->
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div id="archive-floating-bar" class="archive-floating-bar hidden">
|
||||
<span id="selected-count-label">0 items selected</span>
|
||||
@@ -574,7 +574,6 @@ function formatDeleteDate(dateStr: string | null) {
|
||||
background: #f3a642;
|
||||
}
|
||||
|
||||
/* Floating Bar */
|
||||
.archive-floating-bar {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
|
||||
@@ -56,19 +56,16 @@ const filteringSearchTypes=selectedSearchTypes.length>0;
|
||||
const selectedTags=Astro.url.searchParams.getAll("tag").map(t=>t.trim().toLowerCase()).filter(Boolean);
|
||||
const selectedIngredients=Astro.url.searchParams.getAll("ingredient").map(i=>i.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
// Default to "recipe" when not in a global search
|
||||
const type = ["recipe","ingredient","book","purchase"].includes(requested ?? "")
|
||||
? requested
|
||||
: (query ? undefined : "recipe");
|
||||
|
||||
// Recipe attention filters
|
||||
const emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1";
|
||||
const missingYield=Astro.url.searchParams.get("missing_yield")==="1";
|
||||
const placeholderSteps=Astro.url.searchParams.get("placeholder_steps")==="1";
|
||||
const attentionRecipe=Astro.url.searchParams.get("attention")==="1";
|
||||
const recipeFilterActive=emptyRecipe||missingYield||placeholderSteps||attentionRecipe;
|
||||
|
||||
// Ingredient attention filters
|
||||
const unused=Astro.url.searchParams.get("unused")==="1";
|
||||
const missingCost=Astro.url.searchParams.get("missing_cost")==="1";
|
||||
const noPurchase=Astro.url.searchParams.get("no_purchase")==="1" || Astro.url.searchParams.get("no_usda")==="1";
|
||||
@@ -111,7 +108,6 @@ function parseItemTags(tagsJson?: string, categoriesJson?: string): string[] {
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
// Compute tag counts
|
||||
const activeDataset = type === "ingredient" ? ingredients : recipes;
|
||||
const tagCountsMap = new Map<string, number>();
|
||||
activeDataset.forEach((item) => {
|
||||
|
||||
@@ -8,6 +8,12 @@ import { number, titleCase } from "../../../../lib/format";
|
||||
import PurchaseItemForm from "../../../../components/PurchaseItemForm.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { getTabIconHtml, type TabIconKey } from "../../../../lib/icons";
|
||||
const customNutrients = [
|
||||
["energy_kcal", "Calories", "kcal"], ["total_fat_g", "Total fat", "g"], ["saturated_fat_g", "Saturated fat", "g"], ["trans_fat_g", "Trans fat", "g"],
|
||||
["cholesterol_mg", "Cholesterol", "mg"], ["sodium_mg", "Sodium", "mg"], ["carbohydrate_g", "Total carbohydrate", "g"], ["dietary_fiber_g", "Dietary fiber", "g"],
|
||||
["total_sugars_g", "Total sugars", "g"], ["added_sugars_g", "Added sugars", "g"], ["protein_g", "Protein", "g"], ["vitamin_d_mcg", "Vitamin D", "mcg"],
|
||||
["calcium_mg", "Calcium", "mg"], ["iron_mg", "Iron", "mg"], ["potassium_mg", "Potassium", "mg"], ["vitamin_c_mg", "Vitamin C", "mg"],
|
||||
] as const;
|
||||
const id = Astro.params.id!;
|
||||
const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1";
|
||||
const database = openDatabase({ readOnly: false });
|
||||
@@ -82,6 +88,28 @@ if (Astro.request.method === "POST") {
|
||||
} catch(cause) { database.exec("ROLLBACK"); throw cause; }
|
||||
refreshSiteProjection(database); database.close(); return Astro.redirect(`/app/ingredients/${id}/?edit=1&message=${encodeURIComponent(`USDA mapping, nutrition, name, and ${portions.length} equivalencies updated`)}#nutrition`,303);
|
||||
}
|
||||
if (intent === "custom_nutrition") {
|
||||
const servingWeightG=Number(form.get("serving_weight_g")), displayName=String(form.get("display_name")??"").trim(), sourceTitle=String(form.get("source_title")??"").trim();
|
||||
if(!(servingWeightG>0))throw new Error("Serving weight must be greater than zero.");
|
||||
if(!displayName)throw new Error("A nutrition display name is required.");
|
||||
const nutrition:Record<string,number>={};
|
||||
for(const [key,label] of customNutrients){const value=Number(form.get(key));if(!Number.isFinite(value)||value<0)throw new Error(`${label} must be zero or greater.`);nutrition[key]=value*100/servingWeightG;}
|
||||
const today=new Date().toISOString().slice(0,10),mappingId=`custom_nutrition_${id}`,source={source_type:"manual",title:displayName,publisher:sourceTitle||"Custom nutrition fact",retrieved_at:today,reviewed:true,reviewed_at:today,serving_weight_g:servingWeightG,notes:"Values entered per stated serving and normalized to 100 g."};
|
||||
const current=database.prepare("SELECT source_json FROM ingredients WHERE id=?").get(id) as any,canonical=JSON.parse(current.source_json);canonical.nutrition_mapping_ids=[mappingId];
|
||||
database.exec("BEGIN IMMEDIATE");try{database.prepare("UPDATE ingredients SET source_json=? WHERE id=?").run(JSON.stringify(canonical),id);database.prepare("DELETE FROM source_mappings WHERE subject_type='ingredient' AND subject_id=? AND mapping_type='nutrition'").run(id);database.prepare("INSERT INTO source_mappings VALUES (?,?,?,?,?,?,?)").run(mappingId,"ingredient",id,"nutrition","reviewed",JSON.stringify(source),JSON.stringify(nutrition));database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/ingredients/${id}/?edit=1&message=${encodeURIComponent("Custom nutrition saved")}#nutrition`,303);
|
||||
}
|
||||
if(intent==="non_edible_nutrition"){
|
||||
const today=new Date().toISOString().slice(0,10),mappingId=`non_edible_${id}`,source={source_type:"non_edible",title:"Excluded from finished-food nutrition",reviewed:true,reviewed_at:today,notes:String(form.get("exclusion_reason")??"").trim()||"Ingredient is not consumed in the finished food."};
|
||||
const current=database.prepare("SELECT source_json FROM ingredients WHERE id=?").get(id) as any,canonical=JSON.parse(current.source_json);canonical.nutrition_mapping_ids=[mappingId];
|
||||
database.exec("BEGIN IMMEDIATE");try{database.prepare("UPDATE ingredients SET source_json=? WHERE id=?").run(JSON.stringify(canonical),id);database.prepare("DELETE FROM source_mappings WHERE subject_type='ingredient' AND subject_id=? AND mapping_type='nutrition'").run(id);database.prepare("INSERT INTO source_mappings VALUES (?,?,?,?,?,?,?)").run(mappingId,"ingredient",id,"nutrition","reviewed",JSON.stringify(source),JSON.stringify({energy_kcal:0}));database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/ingredients/${id}/?edit=1&message=${encodeURIComponent("Ingredient excluded from nutrition")}#nutrition`,303);
|
||||
}
|
||||
if(intent==="clear_nutrition"){
|
||||
const current=database.prepare("SELECT source_json FROM ingredients WHERE id=?").get(id) as any,canonical=JSON.parse(current.source_json);canonical.nutrition_mapping_ids=[];
|
||||
database.exec("BEGIN IMMEDIATE");try{database.prepare("UPDATE ingredients SET source_json=? WHERE id=?").run(JSON.stringify(canonical),id);database.prepare("DELETE FROM source_mappings WHERE subject_type='ingredient' AND subject_id=? AND mapping_type='nutrition'").run(id);database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/ingredients/${id}/?edit=1&message=${encodeURIComponent("Nutrition data cleared")}#nutrition`,303);
|
||||
}
|
||||
} catch(cause) { error=cause instanceof Error?cause.message:"Unable to save."; }
|
||||
}
|
||||
const ingredient = database.prepare("SELECT * FROM ingredients WHERE id=?").get(id) as any;
|
||||
@@ -100,6 +128,7 @@ const nutrientLabels: Record<string, [string, string]> = {
|
||||
energy_kcal: ["Calories", "kcal"], protein_g: ["Protein", "g"], total_fat_g: ["Total fat", "g"], saturated_fat_g: ["Saturated fat", "g"],
|
||||
carbohydrate_g: ["Carbohydrate", "g"], dietary_fiber_g: ["Dietary fiber", "g"], total_sugars_g: ["Total sugars", "g"], cholesterol_mg: ["Cholesterol", "mg"],
|
||||
sodium_mg: ["Sodium", "mg"], potassium_mg: ["Potassium", "mg"], calcium_mg: ["Calcium", "mg"], iron_mg: ["Iron", "mg"], vitamin_d_mcg: ["Vitamin D", "mcg"],
|
||||
vitamin_c_mg: ["Vitamin C", "mg"], trans_fat_g:["Trans fat","g"], added_sugars_g:["Added sugars","g"],
|
||||
};
|
||||
const nutritionMappings = mappings.map((mapping) => {
|
||||
const source = JSON.parse(mapping.source_json);
|
||||
@@ -108,6 +137,11 @@ const nutritionMappings = mappings.map((mapping) => {
|
||||
return { ...mapping, source, sourceUrl, nutrients: Object.entries(nutrition).filter((entry): entry is [string, number] => typeof entry[1] === "number") as Array<[string, number]> };
|
||||
});
|
||||
const usdaMapping = nutritionMappings.find((mapping) => mapping.source.source_type === "usda_fdc");
|
||||
const activeNutritionMapping=nutritionMappings.find((mapping)=>mapping.status==="reviewed");
|
||||
const customMapping=nutritionMappings.find((mapping)=>mapping.source.source_type==="manual");
|
||||
const nonEdibleMapping=nutritionMappings.find((mapping)=>mapping.source.source_type==="non_edible");
|
||||
const customServingWeight=Number(customMapping?.source.serving_weight_g??100);
|
||||
const customValue=(key:string)=>{const value=customMapping?.nutrients.find(([name]:[string,number])=>name===key)?.[1];return value==null?0:value*customServingWeight/100;};
|
||||
const usedIn=database.prepare("SELECT DISTINCT r.id,r.title FROM recipe_items i JOIN recipes r ON r.id=i.recipe_id WHERE i.ingredient_id=? ORDER BY r.title").all(id) as any[];
|
||||
const units=database.prepare("SELECT id,name,symbol,dimension FROM units ORDER BY dimension,name").all() as any[];
|
||||
const mergeCandidates=editing?database.prepare("SELECT id,name FROM ingredients WHERE id<>? AND deleted_at IS NULL ORDER BY name").all(id) as any[]:[];
|
||||
@@ -404,29 +438,26 @@ database.close();
|
||||
|
||||
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition">
|
||||
<h2>Nutrition</h2>
|
||||
<p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>
|
||||
<p class="panel-intro">Connect a USDA food, enter a reviewed custom nutrition fact, or explicitly exclude a non-edible item.</p>
|
||||
{editing && (
|
||||
<form method="post" class="usda-id-form">
|
||||
<label>
|
||||
<span>USDA FoodData Central ID</span>
|
||||
<input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/>
|
||||
</label>
|
||||
<button name="intent" value="usda_mapping">Update from USDA</button>
|
||||
{usdaMapping?.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}
|
||||
</form>
|
||||
<div class="nutrition-source-editors">
|
||||
<details open={!activeNutritionMapping||Boolean(usdaMapping)}><summary>USDA FoodData Central</summary><form method="post" class="usda-id-form"><label><span>FDC ID</span><input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/></label><button name="intent" value="usda_mapping">Connect USDA food</button>{usdaMapping?.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}</form></details>
|
||||
<details open={Boolean(customMapping)}><summary>Custom nutrition fact</summary><form method="post" class="custom-nutrition-form"><p>Enter values from a supplier specification or package label for one serving. Values are normalized to 100 g for recipe calculations.</p><div class="custom-nutrition-meta"><label><span>Display name</span><input name="display_name" required value={customMapping?.source.title??ingredient.name}/></label><label><span>Serving weight</span><span class="nutrition-unit-input"><input name="serving_weight_g" type="number" min="0.0001" step="any" required value={customServingWeight}/><i>g</i></span></label><label><span>Source</span><input name="source_title" value={customMapping?.source.publisher??""} placeholder="Package label or specification"/></label></div><div class="custom-nutrient-grid">{customNutrients.map(([key,label,unit])=><label><span>{label}</span><span class="nutrition-unit-input"><input name={key} type="number" min="0" step="any" required value={customValue(key)}/><i>{unit}</i></span></label>)}</div><button name="intent" value="custom_nutrition">Save custom nutrition</button></form></details>
|
||||
<details open={Boolean(nonEdibleMapping)}><summary>Exclude as non-edible</summary><form method="post" class="non-edible-form"><p>Use only when this item is not consumed in the finished food—not merely because USDA has no matching record.</p><label><span>Reason</span><input name="exclusion_reason" required value={nonEdibleMapping?.source.notes??""} placeholder="e.g. removed processing aid"/></label><button name="intent" value="non_edible_nutrition">Mark non-edible</button></form></details>
|
||||
</div>
|
||||
)}
|
||||
{usdaMapping ? (
|
||||
{activeNutritionMapping ? (
|
||||
<article class="ingredient-nutrition">
|
||||
<div class="nutrition-mapping-head">
|
||||
<div>
|
||||
<strong>{usdaMapping.source.title}</strong>
|
||||
<small>{usdaMapping.status} · USDA FoodData Central</small>
|
||||
<strong>{activeNutritionMapping.source.title}</strong>
|
||||
<small>{activeNutritionMapping.status} · {usdaMapping?"USDA FoodData Central":nonEdibleMapping?"Non-edible exclusion":"Custom nutrition"}</small>
|
||||
</div>
|
||||
{usdaMapping.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}
|
||||
{usdaMapping?.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}
|
||||
</div>
|
||||
{usdaMapping.nutrients.length ? (
|
||||
{nonEdibleMapping?<p class="notice">Excluded from finished-food nutrition. {nonEdibleMapping.source.notes}</p>:activeNutritionMapping.nutrients.length ? (
|
||||
<dl>
|
||||
{usdaMapping.nutrients.map((entry: [string, number]) => {
|
||||
{activeNutritionMapping.nutrients.map((entry: [string, number]) => {
|
||||
const [key, value] = entry;
|
||||
const [label, unit] = nutrientLabels[key] ?? [key.replaceAll("_", " "), ""];
|
||||
return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>;
|
||||
@@ -436,12 +467,13 @@ database.close();
|
||||
<p class="notice">No reviewed nutrient values stored.</p>
|
||||
)}
|
||||
<footer>
|
||||
{usdaMapping.source.external_id && <span>Record {usdaMapping.source.external_id}</span>}
|
||||
{usdaMapping.source.retrieved_at && <span>Retrieved {usdaMapping.source.retrieved_at}</span>}
|
||||
{activeNutritionMapping.source.external_id && <span>Record {activeNutritionMapping.source.external_id}</span>}
|
||||
{(activeNutritionMapping.source.reviewed_at||activeNutritionMapping.source.retrieved_at) && <span>Reviewed {activeNutritionMapping.source.reviewed_at??activeNutritionMapping.source.retrieved_at}</span>}
|
||||
{editing&&<form method="post" data-confirm-message="Clear this ingredient's reviewed nutrition data?"><button name="intent" value="clear_nutrition">Clear nutrition data</button></form>}
|
||||
</footer>
|
||||
</article>
|
||||
) : (
|
||||
<p class="empty-copy">No USDA record mapped.{editing && " Enter an FDC ID above."}</p>
|
||||
<p class="empty-copy">No reviewed nutrition data.{editing && " Choose a source above."}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -473,7 +505,7 @@ database.close();
|
||||
const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');
|
||||
editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';
|
||||
let tags=tagSource.value.split(',').map(value=>value.trim()).filter(Boolean);
|
||||
const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);render();setIngredientDirty()};chip.append(label,remove);return chip}));tagSource.value=tags.join(', ')};
|
||||
const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;const removeTag=(e)=>{e?.stopPropagation?.();tags=tags.filter(value=>value!==tag);tagSource.value=tags.join(', ');render();setIngredientDirty()};remove.onclick=removeTag;chip.onclick=removeTag;chip.append(label,remove);return chip}));tagSource.value=tags.join(', ')};
|
||||
const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setIngredientDirty()}entry.value='';render()};
|
||||
entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setIngredientDirty()}});entry.addEventListener('blur',add);tagSource.after(editor);editor.append(chips,entry);render();
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ if (!count) {
|
||||
return new Response("Inventory count session not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch all available units and active ingredients for the quick-add selector
|
||||
const allUnits = database.prepare("SELECT id, name, symbol FROM units ORDER BY name").all() as Array<{ id: string; name: string; symbol: string }>;
|
||||
const activeIngredients = database.prepare("SELECT id, name FROM ingredients WHERE status='active' AND deleted_at IS NULL ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
|
||||
@@ -129,7 +128,7 @@ const isCompleted = count.status === "completed";
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Location Filter Tabs -->
|
||||
|
||||
<nav class="location-tabs" aria-label="Filter count sheet by location">
|
||||
<a
|
||||
href={`/app/inventory/${id}/`}
|
||||
@@ -152,7 +151,7 @@ const isCompleted = count.status === "completed";
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<!-- Count Sheet Table Form -->
|
||||
|
||||
<form method="post" id="count-sheet-form">
|
||||
<input type="hidden" name="intent" id="form-intent" value="save" />
|
||||
|
||||
@@ -485,7 +484,6 @@ const isCompleted = count.status === "completed";
|
||||
border: 1px solid #a3ebd4;
|
||||
}
|
||||
|
||||
/* Location tabs */
|
||||
.location-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -521,7 +519,6 @@ const isCompleted = count.status === "completed";
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.count-table-container {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
@@ -609,7 +606,6 @@ const isCompleted = count.status === "completed";
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Add item bar */
|
||||
.add-item-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -28,7 +28,7 @@ if (Astro.request.method === "POST") {
|
||||
if (!title) throw new Error("Title is required.");
|
||||
if (!counted_at) throw new Error("Count date is required.");
|
||||
|
||||
const createdId = createInventoryCount(database, {
|
||||
createInventoryCount(database, {
|
||||
title,
|
||||
counted_at,
|
||||
notes,
|
||||
@@ -166,7 +166,7 @@ database.close();
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- New Count Session Modal Dialog -->
|
||||
|
||||
<dialog id="new-count-dialog" class="count-modal-dialog">
|
||||
<form method="post">
|
||||
<input type="hidden" name="intent" value="create" />
|
||||
@@ -402,7 +402,6 @@ database.close();
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.count-modal-dialog {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -48,7 +48,7 @@ if (Astro.request.method === "POST") {
|
||||
const source=JSON.parse(autoRow.source_json??"{}");
|
||||
if(autoRow.auto_yield){
|
||||
const original=source.auto_yield_original;
|
||||
if(original?.quantity>0&&original?.unit_id)saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:original.quantity,yield_unit_id:original.unit_id,yield_servings:current.yield_servings,yield_basis:original.basis??null});
|
||||
if(original?.quantity>0&&original?.unit_id)saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:original.quantity,yield_unit_id:original.unit_id,yield_servings:null,yield_basis:original.basis??null});
|
||||
delete source.auto_yield_original;database.prepare("UPDATE recipes SET auto_yield=0,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1`,303);
|
||||
}
|
||||
const calcContextNow = getRecipeCalculationContext(database, id);
|
||||
@@ -61,11 +61,11 @@ if (Astro.request.method === "POST") {
|
||||
if ("ingredient_id" in item.reference) {
|
||||
const ingredient = ingredientsNow.get(item.reference.ingredient_id);
|
||||
if (!ingredient) throw new Error("ingredient not found");
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", ingredient, unitsNow).quantity;
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", ingredient, unitsNow).quantity * (item.nutrition_retention_factor ?? 1);
|
||||
} else {
|
||||
const child = recipesNow.get(item.reference.recipe_id);
|
||||
if (!child) throw new Error("sub-recipe not found");
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", { schema_version: 2, id: child.id, name: child.title, status: "active", categories: [], measure_conversions: child.measure_conversions }, unitsNow).quantity;
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", { schema_version: 2, id: child.id, name: child.title, status: "active", categories: [], measure_conversions: child.measure_conversions }, unitsNow).quantity * (item.nutrition_retention_factor ?? 1);
|
||||
}
|
||||
} catch {
|
||||
conversionFailures.push("ingredient_id" in item.reference ? ingredientsNow.get(item.reference.ingredient_id)?.name ?? item.reference.ingredient_id : recipesNow.get(item.reference.recipe_id)?.title ?? item.reference.recipe_id);
|
||||
@@ -74,19 +74,69 @@ if (Astro.request.method === "POST") {
|
||||
if (conversionFailures.length) throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`);
|
||||
if (!(calculatedYieldWeightG > 0)) throw new Error("No convertible ingredient weights are available for automatic yield.");
|
||||
source.auto_yield_original = { quantity: current.yield_quantity, unit_id: current.yield_unit_id, basis: current.yield_basis };
|
||||
saveRecipeMetadata(database, id, current.save_version, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: calculatedYieldWeightG, yield_unit_id: "gram", yield_servings: current.yield_servings, yield_basis: "theoretical" });
|
||||
saveRecipeMetadata(database, id, current.save_version, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: calculatedYieldWeightG, yield_unit_id: "gram", yield_servings: null, yield_basis: "theoretical" });
|
||||
database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source), id);
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`, 303);
|
||||
}
|
||||
if (form.get("intent") === "nutrition_servings") {
|
||||
if (form.get("intent") === "nutrition_serving_size") {
|
||||
const current = editableRecipe(database, id);
|
||||
if (!current) throw new Error("Recipe not found.");
|
||||
const yieldServings = Number(form.get("yield_servings"));
|
||||
if (!Number.isFinite(yieldServings) || yieldServings <= 0) throw new Error("Servings must be greater than zero.");
|
||||
const servingQuantity = Number(form.get("serving_quantity"));
|
||||
const servingUnitId = String(form.get("serving_unit_id") ?? "");
|
||||
if (!Number.isFinite(servingQuantity) || servingQuantity <= 0) throw new Error("Serving size must be greater than zero.");
|
||||
if (!database.prepare("SELECT 1 FROM units WHERE id=? AND dimension IN ('mass','volume','count')").get(servingUnitId)) throw new Error("Serving size uses an unsupported unit.");
|
||||
const expectedVersion = Number(form.get("save_version"));
|
||||
saveRecipeMetadata(database, id, expectedVersion, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: current.yield_quantity, yield_unit_id: current.yield_unit_id, yield_servings: yieldServings, yield_basis: current.yield_basis });
|
||||
if (current.save_version !== expectedVersion) throw new Error("This recipe changed in another tab. Reload before saving.");
|
||||
const context = getRecipeCalculationContext(database, id);
|
||||
if (!context) throw new Error("Recipe calculation context not found.");
|
||||
const servingSize = { quantity: servingQuantity, unit_id: servingUnitId };
|
||||
const conversionSubject = { schema_version: 2 as const, id, name: current.title, status: "active", categories: [], measure_conversions: context.domainRecipe.measure_conversions };
|
||||
try {
|
||||
convertWithIngredientMeasures(servingSize, "gram", conversionSubject, context.units);
|
||||
convertWithIngredientMeasures(context.domainRecipe.yield.amount, "gram", conversionSubject, context.units);
|
||||
} catch { throw new Error("Serving size and total yield must connect through a weight equivalency."); }
|
||||
const source = JSON.parse(current.source_json ?? "{}");
|
||||
source.yield = { ...(source.yield ?? {}), serving_size: servingSize };
|
||||
const update = database.prepare("UPDATE recipes SET save_version=?,yield_servings=NULL,source_json=? WHERE id=? AND save_version=?").run(current.save_version + 1, JSON.stringify(source), id, current.save_version);
|
||||
if (update.changes !== 1) throw new Error("This recipe changed in another tab. Reload before saving.");
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`, 303);
|
||||
}
|
||||
if (form.get("intent") === "nutrition_yield_after_cooking") {
|
||||
const current = editableRecipe(database, id);
|
||||
if (!current) throw new Error("Recipe not found.");
|
||||
const expectedVersion = Number(form.get("save_version"));
|
||||
if (current.save_version !== expectedVersion) throw new Error("This recipe changed in another tab. Reload before saving.");
|
||||
const itemId = String(form.get("item_id") ?? "");
|
||||
const percent = Number(form.get("yield_after_cooking"));
|
||||
if (!Number.isFinite(percent) || percent < 0 || percent > 100) throw new Error("Yield after cooking must be between 0% and 100%.");
|
||||
if (!database.prepare("SELECT 1 FROM recipe_items WHERE recipe_id=? AND id=?").get(id, itemId)) throw new Error("Recipe ingredient not found.");
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database.prepare("UPDATE recipe_items SET nutrition_retention_factor=? WHERE recipe_id=? AND id=?").run(percent / 100, id, itemId);
|
||||
const autoEnabled = Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as {auto_yield:number}).auto_yield);
|
||||
let calculatedYield: number | undefined;
|
||||
if (autoEnabled) {
|
||||
const context = getRecipeCalculationContext(database, id);
|
||||
if (!context) throw new Error("Recipe calculation context not found.");
|
||||
calculatedYield = 0;
|
||||
for (const item of context.domainRecipe.components.flatMap(component => component.items).filter(item => !item.optional)) {
|
||||
const subject = "ingredient_id" in item.reference ? context.ingredients.get(item.reference.ingredient_id) : context.recipes.get(item.reference.recipe_id);
|
||||
if (!subject) throw new Error("Recipe item could not be resolved.");
|
||||
const conversionSubject = "ingredient_id" in item.reference ? subject as Ingredient : { schema_version: 2 as const, id: subject.id, name: (subject as Recipe).title, status: "active", categories: [], measure_conversions: (subject as Recipe).measure_conversions };
|
||||
calculatedYield += convertWithIngredientMeasures(item.amount, "gram", conversionSubject, context.units).quantity * (item.nutrition_retention_factor ?? 1);
|
||||
}
|
||||
}
|
||||
const update = calculatedYield != null
|
||||
? database.prepare("UPDATE recipes SET save_version=?,yield_quantity=?,yield_unit_id='gram',yield_basis='theoretical' WHERE id=? AND save_version=?").run(current.save_version + 1, calculatedYield, id, current.save_version)
|
||||
: database.prepare("UPDATE recipes SET save_version=? WHERE id=? AND save_version=?").run(current.save_version + 1, id, current.save_version);
|
||||
if (update.changes !== 1) throw new Error("This recipe changed in another tab. Reload before saving.");
|
||||
database.exec("COMMIT");
|
||||
} catch (cause) { database.exec("ROLLBACK"); throw cause; }
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`, 303);
|
||||
}
|
||||
@@ -102,8 +152,7 @@ if (Astro.request.method === "POST") {
|
||||
const title = String(form.get("title") ?? "").trim();
|
||||
const yieldQuantity = Number(form.get("yield_quantity"));
|
||||
const yieldUnitId = String(form.get("yield_unit_id") ?? "");
|
||||
const servingsText = String(form.get("yield_servings") ?? "").trim();
|
||||
const yieldServings = servingsText ? Number(servingsText) : null;
|
||||
const yieldServings = null;
|
||||
const yieldBasis = String(form.get("yield_basis") ?? "") || null;
|
||||
const categories = String(form.get("categories") ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
||||
const tags = String(form.get("tags") ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
||||
@@ -158,12 +207,14 @@ const nutritionIngredients=domainRecipe.components.flatMap(component=>component.
|
||||
const ingredient=ingredientMap.get(item.reference.ingredient_id)!;
|
||||
const mapping=(ingredient.nutrition_mapping_ids??[]).map(mappingId=>nutritionMappingMap.get(mappingId)).find(mapping=>mapping?.mapping_type==="nutrition"&&mapping.status==="reviewed"&&Object.keys(mapping.nutrition_per_100g??{}).length>0);
|
||||
let weightG:number|undefined;try{weightG=convertWithIngredientMeasures(item.amount,"gram",ingredient,unitMap).quantity;}catch{}
|
||||
return {id:item.id,name:ingredient.name,href:`/app/ingredients/${ingredient.id}/${readOnlyMode?"":"?edit=1"}#nutrition`,weightG,mapped:Boolean(mapping),source:mapping?.source.title,kind:"ingredient" as const};
|
||||
const yieldAfterCooking=item.nutrition_retention_factor??1;
|
||||
return {id:item.id,name:ingredient.name,href:`/app/ingredients/${ingredient.id}/${readOnlyMode?"":"?edit=1"}#nutrition`,weightG,netWeightG:weightG==null?undefined:weightG*yieldAfterCooking,yieldAfterCooking,mapped:Boolean(mapping),source:mapping?.source.title,kind:"ingredient" as const};
|
||||
}
|
||||
const child=recipeMap.get(item.reference.recipe_id)!;
|
||||
const childNutrition=calculateNutrition(child,{recipes:recipeMap,ingredients:ingredientMap,units:unitMap,mappings:nutritionMappingMap});
|
||||
let weightG:number|undefined;try{weightG=convertWithIngredientMeasures(item.amount,"gram",{id:child.id,name:child.title,schema_version:2,status:"active",categories:[],measure_conversions:child.measure_conversions},unitMap).quantity;}catch{}
|
||||
return {id:item.id,name:child.title,href:`/app/recipes/${child.id}/#nutrition`,weightG,mapped:childNutrition.completeness>=.999,source:`Sub-recipe · ${Math.round(childNutrition.completeness*100)}% mapped`,kind:"recipe" as const};
|
||||
const yieldAfterCooking=item.nutrition_retention_factor??1;
|
||||
return {id:item.id,name:child.title,href:`/app/recipes/${child.id}/#nutrition`,weightG,netWeightG:weightG==null?undefined:weightG*yieldAfterCooking,yieldAfterCooking,mapped:childNutrition.completeness>=.999,source:`Sub-recipe · ${Math.round(childNutrition.completeness*100)}% mapped`,kind:"recipe" as const};
|
||||
});
|
||||
database.close();
|
||||
const categories = JSON.parse(recipe.categories_json).join(", ");
|
||||
@@ -180,10 +231,12 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
) : !readOnlyMode && (
|
||||
<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
|
||||
)}
|
||||
<details class="detail-actions-menu">
|
||||
<summary aria-label="Recipe actions"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary>
|
||||
<div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div>
|
||||
</details>
|
||||
{!readOnlyMode && (
|
||||
<details class="detail-actions-menu">
|
||||
<summary aria-label="Recipe actions"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary>
|
||||
<div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</DetailUtility>
|
||||
|
||||
@@ -202,15 +255,14 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
<section class="recipe-overview-strip">
|
||||
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
|
||||
<input type="hidden" name="save_version" value={recipe.save_version} />
|
||||
{saved && <div class="success-notice">Changes saved.</div>}
|
||||
{error && <div class="notice">{error}</div>}
|
||||
{saved && <div class="success-notice"><span>Changes saved.</span><button type="button" class="notice-dismiss-btn" onclick="this.parentElement.remove()" aria-label="Dismiss">×</button></div>}
|
||||
{error && <div class="notice"><span>{error}</span><button type="button" class="notice-dismiss-btn" onclick="this.parentElement.remove()" aria-label="Dismiss">×</button></div>}
|
||||
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}>
|
||||
<span class="yield-title-label">Total Yield</span>
|
||||
<div class="yield-inputs-row">
|
||||
<label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label>
|
||||
<label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label>
|
||||
</div>
|
||||
<input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/>
|
||||
<input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>
|
||||
</div>
|
||||
<input type="hidden" name="summary" value={recipe.summary??""}/>
|
||||
@@ -232,7 +284,7 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
) : (
|
||||
<section class="recipe-view-formula-pane recipe-tab-panel active" data-recipe-panel="formula">
|
||||
<div class="recipe-view-formula">
|
||||
<RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false} showPercentControls={false} shelfLife={shelfLife}/>
|
||||
<RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} showDerived={false} showPercentControls={false} shelfLife={shelfLife}/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -264,7 +316,6 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
</select>
|
||||
<input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/>
|
||||
</fieldset>
|
||||
<label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label>
|
||||
<label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label>
|
||||
</form>
|
||||
</section>
|
||||
@@ -331,7 +382,7 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
</div>
|
||||
|
||||
<div class="uom-equation-row">
|
||||
<!-- Weight Group -->
|
||||
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Weight</span>
|
||||
<div class="uom-box-pair">
|
||||
@@ -356,10 +407,10 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals Sign -->
|
||||
|
||||
<div class="uom-equation-separator">=</div>
|
||||
|
||||
<!-- Volume Group -->
|
||||
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Volume</span>
|
||||
<div class="uom-box-pair">
|
||||
@@ -384,10 +435,10 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals Sign -->
|
||||
|
||||
<div class="uom-equation-separator">=</div>
|
||||
|
||||
<!-- Each Group -->
|
||||
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Each</span>
|
||||
<div class="uom-box-pair">
|
||||
@@ -396,7 +447,7 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
id="uom-container-qty"
|
||||
name="container_conversion"
|
||||
class="uom-qty-input"
|
||||
value={domainRecipe.yield.servings || 1}
|
||||
value={nutrition.servingsPerBatch || 1}
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
@@ -435,7 +486,7 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
</section>
|
||||
|
||||
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition">
|
||||
<LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable={editing} saveVersion={recipe.save_version}/>
|
||||
<LiveNutritionValues client:load nutrition={nutrition} ingredients={nutritionIngredients} editable={editing} saveVersion={recipe.save_version} units={units}/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
@@ -444,7 +495,6 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
<section class="recipe-additional-view">
|
||||
<div>
|
||||
<h2>Additional details</h2>
|
||||
{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}
|
||||
{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}
|
||||
{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}
|
||||
</div>
|
||||
@@ -476,7 +526,7 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
{editing&&<script is:inline>
|
||||
let recipeDirty=false;
|
||||
const setRecipeDirty=(value=true)=>{recipeDirty=value;document.querySelector('#recipe-done')?.classList.toggle('dirty',value)};
|
||||
const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()};
|
||||
const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;const removeTag=(e)=>{e?.stopPropagation?.();tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};remove.onclick=removeTag;chip.onclick=removeTag;chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()};
|
||||
document.addEventListener('recipe:dirty',event=>setRecipeDirty(Boolean(event.detail)));
|
||||
document.querySelector('#recipe-details-form')?.addEventListener('input',()=>setRecipeDirty());
|
||||
document.querySelector('#recipe-additional-form')?.addEventListener('input',()=>setRecipeDirty());
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { newRecipeId } from "../../../../lib/recipe-id";
|
||||
import { openDatabase,refreshSiteProjection } from "../../../../lib/database";
|
||||
const database=openDatabase({readOnly:false});
|
||||
if(!database)return Astro.redirect("/app/?error=database-missing",303);
|
||||
let error:string|undefined;
|
||||
const units=database.prepare("SELECT id,name,symbol FROM units ORDER BY dimension,name").all() as Array<{id:string;name:string;symbol:string}>;
|
||||
if(Astro.request.method==="POST"){try{const form=await Astro.request.formData();const title=String(form.get("title")??"").trim();const yieldQuantity=Number(form.get("yield_quantity"));const yieldUnitId=String(form.get("yield_unit_id")??"");if(!title)throw new Error("Title is required.");if(!Number.isFinite(yieldQuantity)||yieldQuantity<=0)throw new Error("Yield must be greater than zero.");if(!database.prepare("SELECT 1 FROM units WHERE id=?").get(yieldUnitId))throw new Error("Unknown yield unit.");const base=title.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g,"_").replace(/^_|_$/g,"")||"recipe";let id=base,suffix=2;while(database.prepare("SELECT 1 FROM recipes WHERE id=?").get(id))id=`${base}_${suffix++}`;database.exec("BEGIN IMMEDIATE");try{database.prepare(`INSERT INTO recipes(id,schema_version,title,summary,categories_json,tags_json,yield_quantity,yield_unit_id,yield_servings,yield_basis,scaling_mode,scaling_basis_id,scaling_basis_quantity,scaling_basis_unit_id,notes_json,source_json) VALUES (?,2,?,NULL,'[]','[]',?,?,NULL,NULL,'amount',NULL,NULL,NULL,'[]','{}')`).run(id,title,yieldQuantity,yieldUnitId);database.prepare("INSERT INTO recipe_components(recipe_id,id,position,name,notes_json) VALUES (?,'main',1,'Main','[]')").run(id);database.prepare("INSERT INTO recipe_steps(recipe_id,id,position,instruction,critical_control_point) VALUES (?,'step_1',1,'TODO: Add preparation instructions.',0)").run(id);database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1`,303);}catch(cause){error=cause instanceof Error?cause.message:"Unable to create recipe.";}}
|
||||
if(Astro.request.method==="POST"){try{const form=await Astro.request.formData();const title=String(form.get("title")??"").trim();const yieldQuantity=Number(form.get("yield_quantity"));const yieldUnitId=String(form.get("yield_unit_id")??"");if(!title)throw new Error("Title is required.");if(!Number.isFinite(yieldQuantity)||yieldQuantity<=0)throw new Error("Yield must be greater than zero.");if(!database.prepare("SELECT 1 FROM units WHERE id=?").get(yieldUnitId))throw new Error("Unknown yield unit.");let id=newRecipeId();while(database.prepare("SELECT 1 FROM recipes WHERE id=?").get(id))id=newRecipeId();database.exec("BEGIN IMMEDIATE");try{database.prepare(`INSERT INTO recipes(id,schema_version,title,summary,categories_json,tags_json,yield_quantity,yield_unit_id,yield_servings,yield_basis,scaling_mode,scaling_basis_id,scaling_basis_quantity,scaling_basis_unit_id,notes_json,source_json) VALUES (?,2,?,NULL,'[]','[]',?,?,NULL,NULL,'amount',NULL,NULL,NULL,'[]','{}')`).run(id,title,yieldQuantity,yieldUnitId);database.prepare("INSERT INTO recipe_components(recipe_id,id,position,name,notes_json) VALUES (?,'main',1,'Main','[]')").run(id);database.prepare("INSERT INTO recipe_steps(recipe_id,id,position,instruction,critical_control_point) VALUES (?,'step_1',1,'TODO: Add preparation instructions.',0)").run(id);database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1`,303);}catch(cause){error=cause instanceof Error?cause.message:"Unable to create recipe.";}}
|
||||
database.close();
|
||||
---
|
||||
<BaseLayout title="New recipe"><section class="shell page-heading"><p class="eyebrow"><a href="/app/?type=recipe">Recipes</a></p><h1>New recipe</h1><p>Create the recipe, then add its ingredients and preparation.</p></section><section class="shell create-entity"><form method="post" class="editor-form"><fieldset><legend>Recipe details</legend>{error&&<div class="notice">{error}</div>}<label><span>Title</span><input name="title" required autofocus /></label><div class="editor-row"><label><span>Initial yield</span><input name="yield_quantity" type="number" min="0.0001" step="any" value="1" required /></label><label><span>Yield unit</span><select name="yield_unit_id">{units.map(unit=><option value={unit.id} selected={unit.id==="gram"}>{unit.name} ({unit.symbol})</option>)}</select></label></div></fieldset><div class="editor-actions"><button>Create recipe</button><a href="/app/?type=recipe">Cancel</a></div></form></section></BaseLayout>
|
||||
@@ -44,7 +44,7 @@ try {
|
||||
</header>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- Database Overview Card -->
|
||||
|
||||
<section class="settings-card db-status-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon status-icon">
|
||||
@@ -86,7 +86,7 @@ try {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Export Backup Card -->
|
||||
|
||||
<section class="settings-card export-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon export-icon">
|
||||
@@ -114,7 +114,7 @@ try {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Import & Restore Card -->
|
||||
|
||||
<section class="settings-card import-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon import-icon">
|
||||
@@ -143,7 +143,7 @@ try {
|
||||
<button type="button" class="btn-secondary" id="browseFileBtn">Browse File</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview Dialog (Hidden until file selected) -->
|
||||
|
||||
<div class="import-preview-box" id="importPreviewBox" style="display: none;">
|
||||
<div class="preview-header">
|
||||
<h3>Backup File Validation</h3>
|
||||
@@ -151,7 +151,7 @@ try {
|
||||
</div>
|
||||
|
||||
<div class="preview-metrics" id="previewMetrics">
|
||||
<!-- Injected via JavaScript -->
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mode-selection-group">
|
||||
@@ -181,7 +181,7 @@ try {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Banner -->
|
||||
|
||||
<div class="import-status-banner" id="importStatusBanner" style="display: none;"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { NutritionResult } from "../lib/nutrition";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
const dailyValues: Record<string, number> = { total_fat_g: 78, saturated_fat_g: 20, cholesterol_mg: 300, sodium_mg: 2300, carbohydrate_g: 275, dietary_fiber_g: 28, vitamin_d_mcg: 20, calcium_mg: 1300, iron_mg: 18, potassium_mg: 4700 };
|
||||
const rows = [["Total Fat", "total_fat_g", "g"], ["Saturated Fat", "saturated_fat_g", "g"], ["Cholesterol", "cholesterol_mg", "mg"], ["Sodium", "sodium_mg", "mg"], ["Total Carbohydrate", "carbohydrate_g", "g"], ["Dietary Fiber", "dietary_fiber_g", "g"], ["Total Sugars", "total_sugars_g", "g"], ["Protein", "protein_g", "g"]] as const;
|
||||
const dailyValues: Record<string, number> = { total_fat_g: 78, saturated_fat_g: 20, cholesterol_mg: 300, sodium_mg: 2300, carbohydrate_g: 275, dietary_fiber_g: 28, vitamin_d_mcg: 20, calcium_mg: 1300, iron_mg: 18, potassium_mg: 4700, vitamin_c_mg:90 };
|
||||
const rows = [["Total Fat", "total_fat_g", "g"], ["Saturated Fat", "saturated_fat_g", "g"], ["Trans Fat", "trans_fat_g", "g"], ["Cholesterol", "cholesterol_mg", "mg"], ["Sodium", "sodium_mg", "mg"], ["Total Carbohydrate", "carbohydrate_g", "g"], ["Dietary Fiber", "dietary_fiber_g", "g"], ["Total Sugars", "total_sugars_g", "g"], ["Added Sugars", "added_sugars_g", "g"], ["Protein", "protein_g", "g"], ["Vitamin D", "vitamin_d_mcg", "mcg"], ["Calcium", "calcium_mg", "mg"], ["Iron", "iron_mg", "mg"], ["Potassium", "potassium_mg", "mg"], ["Vitamin C", "vitamin_c_mg", "mg"]] as const;
|
||||
|
||||
export type NutritionIngredientStatus = { id:string; name:string; href:string; weightG?:number; mapped:boolean; source?:string; kind?:"ingredient"|"recipe" };
|
||||
export type NutritionIngredientStatus = { id:string; name:string; href:string; weightG?:number; netWeightG?:number; yieldAfterCooking:number; mapped:boolean; source?:string; kind?:"ingredient"|"recipe" };
|
||||
|
||||
const nearest=(value:number,increment:number)=>Math.round(value/increment)*increment;
|
||||
export const labelCalories=(value:number)=>value<5?0:nearest(value,value<=50?5:10);
|
||||
@@ -15,26 +16,37 @@ const labelAmount=(key:string,value:number,unit:string)=>{
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
|
||||
export default function NutritionPanel({ nutrition, servings, factor, ingredients=[], editable=false, saveVersion }: { nutrition: NutritionResult; servings?: number; factor: number; ingredients?:NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number }) {
|
||||
const facts = nutrition.perServing ?? nutrition.per100g;
|
||||
if (!facts) return null;
|
||||
const servingWeight = servings && nutrition.yieldWeightG ? nutrition.yieldWeightG / servings : 100;
|
||||
const scaledServings = servings ? servings * factor : undefined;
|
||||
const amount = (key: string, unit: string) => labelAmount(key,facts[key]??0,unit);
|
||||
const dv = (key: string) => dailyValues[key] ? Math.round(((facts[key] ?? 0) / dailyValues[key]) * 100) : undefined;
|
||||
const ready=Boolean(servings)&&nutrition.completeness>=.999;
|
||||
type NutritionUnit={id:string;name:string;symbol:string;dimension?:string};
|
||||
|
||||
export default function NutritionPanel({ nutrition, factor, ingredients=[], editable=false, saveVersion, units=[] }: { nutrition: NutritionResult; factor: number; ingredients?:NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number; units?:NutritionUnit[] }) {
|
||||
const [editingYieldId,setEditingYieldId]=useState<string>();
|
||||
const [editingServingSize,setEditingServingSize]=useState(false);
|
||||
const facts = nutrition.perServing;
|
||||
const hasFacts=Boolean(facts);
|
||||
const displayedFacts=facts??{};
|
||||
const servingWeight = nutrition.servingWeightG;
|
||||
const scaledServings = nutrition.servingsPerBatch ? nutrition.servingsPerBatch * factor : undefined;
|
||||
const servingUnit=units.find(unit=>unit.id===nutrition.servingSize?.unit_id);
|
||||
const amount = (key: string, unit: string) => labelAmount(key,displayedFacts[key]??0,unit);
|
||||
const dv = (key: string) => dailyValues[key] ? Math.round(((displayedFacts[key] ?? 0) / dailyValues[key]) * 100) : undefined;
|
||||
const ready=nutrition.labelReady;
|
||||
return <section class="nutrition-wrap" aria-labelledby="nutrition-heading">
|
||||
<div class="nutrition-readiness">
|
||||
<header><div><h2>Nutrition label</h2><p>{ready?"Ready to print":"Complete the items below to generate a final label."}</p></div><button type="button" disabled={!ready} onClick={()=>window.print()}>Print label</button></header>
|
||||
<div class={`nutrition-check${servings?" complete":""}`}><span>{servings?"✓":"!"}</span><div><strong>Serving size</strong><small>{servings?`${Math.round(servings*factor*100)/100} servings per scaled recipe · ${Math.round(servingWeight)} g each`:editable?"Enter the number of servings this recipe yields.":"Add servings in edit mode."}</small>{editable&&<form method="post" class="nutrition-serving-form"><input type="hidden" name="intent" value="nutrition_servings"/><input type="hidden" name="save_version" value={saveVersion}/><input name="yield_servings" type="number" min="0.0001" step="any" required value={servings??""} aria-label="Servings per recipe"/><button>Save</button></form>}</div></div>
|
||||
{ingredients.length>0&&<div class="nutrition-ingredient-list"><div class="nutrition-list-heading"><strong>Ingredient nutritional data</strong><small>{ingredients.filter(item=>item.mapped).length} of {ingredients.length} connected</small></div>{[...ingredients].sort((a,b)=>(b.weightG??-1)-(a.weightG??-1)).map(item=><a href={item.href}><span class={`nutrition-status-dot${item.mapped?" mapped":""}`}>{item.mapped?"✓":"!"}</span><span><strong>{item.name}</strong><small>{item.mapped?(item.source??(item.kind==="recipe"?"Calculated from sub-recipe":"USDA connected")):"Add nutritional data"}</small></span>{item.weightG!=null&&<em>{Math.round(item.weightG*100)/100} g</em>}<b>›</b></a>)}</div>}
|
||||
<div class={`nutrition-check${nutrition.servingSize?" complete":""}`}>
|
||||
<span>{nutrition.servingSize?"✓":"!"}</span>
|
||||
<div class="nutrition-serving-copy"><strong>Serving size</strong><small>{nutrition.servingSize?`${nutrition.servingSize.quantity} ${servingUnit?.symbol??nutrition.servingSize.unit_id} · ${Math.round(servingWeight??0)} g · ${Math.round((nutrition.servingsPerBatch??0)*100)/100} per recipe`:"Define the amount represented by one serving."}</small></div>
|
||||
{editable&&(editingServingSize||!nutrition.servingSize?<form method="post" class="nutrition-serving-form"><input type="hidden" name="intent" value="nutrition_serving_size"/><input type="hidden" name="save_version" value={saveVersion}/><input name="serving_quantity" type="number" min="0.0001" step="any" required value={nutrition.servingSize?.quantity??""} placeholder="Amount" aria-label="Serving size quantity" autoFocus/><select name="serving_unit_id" required aria-label="Serving size unit"><option value="" selected={!nutrition.servingSize} disabled>Unit</option>{units.filter(unit=>["mass","volume","count"].includes(unit.dimension??"")).map(unit=><option value={unit.id} selected={unit.id===nutrition.servingSize?.unit_id}>{unit.symbol||unit.name}</option>)}</select><button class="nutrition-serving-confirm" aria-label="Save serving size">✓</button>{nutrition.servingSize&&<button type="button" class="nutrition-serving-cancel" aria-label="Cancel serving size editing" onClick={()=>setEditingServingSize(false)}>×</button>}</form>:<button type="button" class="nutrition-serving-edit" aria-label="Edit serving size" onClick={()=>setEditingServingSize(true)}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.21a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg></button>)}
|
||||
</div>
|
||||
{nutrition.readinessIssues.length>0&&<div class="nutrition-readiness-issues"><strong>Label requirements</strong><ul>{nutrition.readinessIssues.map(issue=><li>{issue}</li>)}</ul></div>}
|
||||
{ingredients.length>0&&<div class="nutrition-ingredient-list nutrition-yield-list"><div class="nutrition-list-heading"><strong>Ingredients</strong><small>{ingredients.filter(item=>item.mapped).length} of {ingredients.length} connected</small></div><div class="nutrition-yield-table" role="table" aria-label="Ingredient nutrition and cooking yield"><div class="nutrition-yield-head" role="row"><span>Ingredient / Recipe</span><span>USDA Ingredient</span><span>Yield after cooking</span><span>Net Weight</span></div>{ingredients.map(item=>{const percent=(item.yieldAfterCooking*100).toFixed(2);const editing=editable&&editingYieldId===item.id;return <div class="nutrition-yield-row" role="row"><a href={item.href} class="nutrition-yield-name"><span class={`nutrition-food-icon${item.mapped?" mapped":""}`} aria-hidden="true">◉</span><strong>{item.name}</strong></a><small>{item.mapped?(item.source??(item.kind==="recipe"?"Calculated from sub-recipe":"USDA connected")):"Add nutritional data"}</small>{editing?<form method="post" class="nutrition-yield-form"><input type="hidden" name="intent" value="nutrition_yield_after_cooking"/><input type="hidden" name="save_version" value={saveVersion}/><input type="hidden" name="item_id" value={item.id}/><label><input name="yield_after_cooking" type="number" min="0" max="100" step="0.01" required value={percent} aria-label={`${item.name} yield after cooking`} autoFocus/><span>%</span></label><button class="nutrition-yield-confirm" aria-label={`Save ${item.name} yield after cooking`}>✓</button><button type="button" class="nutrition-yield-cancel" aria-label="Cancel" onClick={()=>setEditingYieldId(undefined)}>×</button></form>:<div class="nutrition-yield-value">{editable&&<button type="button" aria-label={`Edit ${item.name} yield after cooking`} onClick={()=>setEditingYieldId(item.id)}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.21a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg></button>}<strong>{percent}%</strong></div>}<em>{item.netWeightG!=null?`${Math.round(item.netWeightG*100)/100}g`:"—"}</em></div>})}</div></div>}
|
||||
</div>
|
||||
<div class="nutrition-label">
|
||||
<header class="nutrition-title"><h2 id="nutrition-heading">Nutrition Facts</h2>{scaledServings && <p>{Math.round(scaledServings * 100) / 100} servings per scaled recipe</p>}<div class="serving-size"><strong>Serving size</strong><strong>{nutrition.perServing ? `1 serving (${Math.round(servingWeight)} g)` : "100 g"}</strong></div></header>
|
||||
<p class="amount-heading">Amount per serving</p><div class="calorie-row"><strong>Calories</strong><strong>{labelCalories(facts.energy_kcal ?? 0)}</strong></div><p class="dv-heading">% Daily Value*</p>
|
||||
<dl class="nutrition-facts">{rows.filter(([, key]) => facts[key] != null).map(([label, key, unit]) => <div class={key === "protein_g" ? "protein" : ""}><dt><strong>{label}</strong> {amount(key, unit)}</dt>{dv(key) != null && <dd>{dv(key)}%</dd>}</div>)}</dl>
|
||||
{hasFacts&&<div class="nutrition-label">
|
||||
<header class="nutrition-title"><h2 id="nutrition-heading">Nutrition Facts</h2>{scaledServings && <p>{Math.round(scaledServings * 100) / 100} servings per scaled recipe</p>}<div class="serving-size"><strong>Serving size</strong><strong>{nutrition.servingSize && servingWeight ? `${nutrition.servingSize.quantity} ${servingUnit?.symbol??nutrition.servingSize.unit_id} (${Math.round(servingWeight)} g)` : ""}</strong></div></header>
|
||||
<p class="amount-heading">Amount per serving</p><div class="calorie-row"><strong>Calories</strong><strong>{labelCalories(displayedFacts.energy_kcal ?? 0)}</strong></div><p class="dv-heading">% Daily Value*</p>
|
||||
<dl class="nutrition-facts">{rows.filter(([, key]) => displayedFacts[key] != null).map(([label, key, unit]) => <div class={key === "protein_g" ? "protein" : ""}><dt><strong>{label}</strong> {amount(key, unit)}</dt>{dv(key) != null && <dd>{dv(key)}%</dd>}</div>)}</dl>
|
||||
<p class="dv-footnote">* The % Daily Value tells you how much a nutrient in a serving contributes to a daily diet. A 2,000 calorie diet is used for general nutrition advice.</p>
|
||||
</div>
|
||||
</div>}
|
||||
<p class="nutrition-source"><strong>{ready?"Calculated label":"Draft calculation"} · {Math.round(nutrition.completeness * 100)}% mapped.</strong> Scaled batch: {Math.round((nutrition.batch.energy_kcal ?? 0) * factor)} kcal. Nutrition and allergen analysis must be professionally verified before regulatory use.</p>
|
||||
</section>;
|
||||
}
|
||||
@@ -16,7 +16,6 @@ type Props = {
|
||||
mode: "amount" | "percentage" | "hybrid";
|
||||
nutrition: NutritionResult;
|
||||
cost: CostResult;
|
||||
servings?: number;
|
||||
showDerived?: boolean;
|
||||
showPercentControls?: boolean;
|
||||
yieldConversions?: CalculatorItem["measureConversions"];
|
||||
@@ -58,7 +57,7 @@ const SCALE_OPTIONS = [
|
||||
{ value: 5, label: "5x" },
|
||||
];
|
||||
|
||||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [], shelfLife }: Props) {
|
||||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, showDerived = true, showPercentControls = true, yieldConversions = [], shelfLife }: Props) {
|
||||
const [factor, setFactor] = useState(1);
|
||||
const [isCustomScale, setIsCustomScale] = useState(false);
|
||||
const [calculatePercent,setCalculatePercent]=useState(showPercentControls);
|
||||
@@ -124,7 +123,7 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const shelfLifeText = useMemo(() => {
|
||||
if (!shelfLife) return null;
|
||||
if (typeof shelfLife === "string") return shelfLife.trim() || null;
|
||||
@@ -366,15 +365,15 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
))}
|
||||
<div class="formula-total"><span>Scaled ingredient total</span><strong>{number(total)} {basisUnit?.symbol ?? basisUnitId}</strong></div>
|
||||
{unconvertedCount>0&&<p class="formula-conversion-warning">{unconvertedCount} ingredient {unconvertedCount===1?"amount is":"amounts are"} excluded from this total because no weight or volume equivalency is available.</p>}
|
||||
{showDerived && <DerivedValues nutrition={nutrition} cost={cost} servings={servings} factor={validFactor} />}
|
||||
{showDerived && <DerivedValues nutrition={nutrition} cost={cost} factor={validFactor} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DerivedValues({ nutrition, cost, servings, factor }: { nutrition: NutritionResult; cost: CostResult; servings?: number; factor: number }) {
|
||||
export function DerivedValues({ nutrition, cost, factor }: { nutrition: NutritionResult; cost: CostResult; factor: number }) {
|
||||
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: cost.currency, minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||||
return <div class="derived-grid">
|
||||
<div id="nutrition"><NutritionPanel nutrition={nutrition} servings={servings} factor={factor} /></div>
|
||||
<div id="nutrition"><NutritionPanel nutrition={nutrition} factor={factor} /></div>
|
||||
<section id="costing" class="derived-card" aria-labelledby="scaled-cost-heading">
|
||||
<div class="derived-title">
|
||||
<div>
|
||||
@@ -414,10 +413,10 @@ export function DerivedValues({ nutrition, cost, servings, factor }: { nutrition
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function LiveDerivedValues({ nutrition, cost, servings }: { nutrition: NutritionResult; cost: CostResult; servings?: number }) {
|
||||
export function LiveDerivedValues({ nutrition, cost }: { nutrition: NutritionResult; cost: CostResult }) {
|
||||
const [factor,setFactor]=useState(1);
|
||||
useEffect(()=>{const update=(event:Event)=>setFactor((event as CustomEvent<number>).detail);window.addEventListener("recipe-scale-change",update);return()=>window.removeEventListener("recipe-scale-change",update);},[]);
|
||||
return <DerivedValues nutrition={nutrition} cost={cost} servings={servings} factor={factor}/>;
|
||||
return <DerivedValues nutrition={nutrition} cost={cost} factor={factor}/>;
|
||||
}
|
||||
|
||||
function useLiveFactor() {
|
||||
@@ -579,6 +578,6 @@ export function LiveCostValues({ cost, yieldQuantity, yieldUnit="g", editable=fa
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveNutritionValues({ nutrition,servings,ingredients=[],editable=false,saveVersion }: { nutrition:NutritionResult; servings?:number; ingredients?:import("./NutritionPanel").NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number }) {
|
||||
return <NutritionPanel nutrition={nutrition} servings={servings} factor={useLiveFactor()} ingredients={ingredients} editable={editable} saveVersion={saveVersion}/>;
|
||||
export function LiveNutritionValues({ nutrition,ingredients=[],editable=false,saveVersion,units=[] }: { nutrition:NutritionResult; ingredients?:import("./NutritionPanel").NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number; units?:Array<{id:string;name:string;symbol:string;dimension?:string}> }) {
|
||||
return <NutritionPanel nutrition={nutrition} factor={useLiveFactor()} ingredients={ingredients} editable={editable} saveVersion={saveVersion} units={units}/>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
import {
|
||||
BulkIngredientImportModal,
|
||||
BulkPrepStepsModal,
|
||||
@@ -29,6 +29,8 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
setRowQuery,
|
||||
focusedItem,
|
||||
setFocusedItem,
|
||||
draftNotes,
|
||||
setDraftNotes,
|
||||
bulkOpen,
|
||||
setBulkOpen,
|
||||
bulkText,
|
||||
@@ -43,9 +45,11 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
prepError,
|
||||
setPrepError,
|
||||
pendingIngredients,
|
||||
pendingRecipes,
|
||||
methodTarget,
|
||||
state,
|
||||
message,
|
||||
setMessage,
|
||||
dragging,
|
||||
setDragging,
|
||||
calculatePercent,
|
||||
@@ -60,10 +64,12 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
replaceLineReference,
|
||||
createPendingRowIngredient,
|
||||
createPendingIngredient,
|
||||
createPendingRecipe,
|
||||
addBulkIngredients,
|
||||
addBulkPrepSteps,
|
||||
unconvertedYieldItems,
|
||||
} = useRecipeStructure(props);
|
||||
const [focusedQuickAdd, setFocusedQuickAdd] = useState<string>();
|
||||
|
||||
return (
|
||||
<section class="structure-editor">
|
||||
@@ -105,7 +111,17 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p class={state === "error" ? "notice" : "success-notice"}>{message}</p>
|
||||
<div class={`editor-notice-banner ${state === "error" ? "notice" : "success-notice"}`}>
|
||||
<span>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="notice-dismiss-btn"
|
||||
aria-label="Dismiss notice"
|
||||
onClick={() => setMessage("")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autoYield && unconvertedYieldItems() > 0 && (
|
||||
@@ -116,465 +132,482 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table
|
||||
class={`editor-items${
|
||||
calculatePercent && percentMode === "bakers" ? " bakers-mode" : ""
|
||||
}`}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="quantity-column">Qty</th>
|
||||
<th class="unit-column">Unit</th>
|
||||
<th class="entity-column">Ingredient / Recipe</th>
|
||||
<th class="notes-column">Notes</th>
|
||||
{calculatePercent && percentMode === "bakers" && (
|
||||
<th class="base-column">Base</th>
|
||||
)}
|
||||
{calculatePercent && <th class="percent-column">%</th>}
|
||||
<th class="actions-column"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.components.map((component, componentIndex) => (
|
||||
<Fragment key={component.id}>
|
||||
{(data.components.length > 1 ||
|
||||
Boolean(component.name?.trim())) && (
|
||||
<tr
|
||||
class={`component-header-row${
|
||||
dragging?.kind === "component" &&
|
||||
dragging.index === componentIndex
|
||||
? " dragging"
|
||||
: ""
|
||||
}`}
|
||||
key={`header-${component.id}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragging?.kind === "component")
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: move(
|
||||
current.components,
|
||||
dragging.index,
|
||||
componentIndex
|
||||
),
|
||||
}));
|
||||
setDragging(undefined);
|
||||
}}
|
||||
>
|
||||
<td
|
||||
colSpan={
|
||||
calculatePercent
|
||||
? percentMode === "bakers"
|
||||
? 7
|
||||
: 6
|
||||
: 5
|
||||
}
|
||||
>
|
||||
<div class="component-header-content">
|
||||
<input
|
||||
class="component-name-input"
|
||||
aria-label="Component name"
|
||||
placeholder="Edit Header"
|
||||
value={component.name}
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
name: event.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div class="component-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-component-btn"
|
||||
title="Delete section"
|
||||
aria-label="Delete section"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: current.components.filter(
|
||||
(_, index) => index !== componentIndex
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder section"
|
||||
aria-label="Drag to reorder section"
|
||||
draggable
|
||||
onDragStart={() =>
|
||||
setDragging({
|
||||
kind: "component",
|
||||
index: componentIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<div class="components-container">
|
||||
{data.components.map((component, componentIndex) => {
|
||||
const componentId = component.id;
|
||||
const currentQuery = query[componentId] ?? "";
|
||||
const currentDraftNote = draftNotes[componentId] ?? "";
|
||||
|
||||
{(component.notes ?? []).map((note, noteIndex) => (
|
||||
<tr
|
||||
class="component-note-row"
|
||||
key={`note-${component.id}-${noteIndex}`}
|
||||
>
|
||||
<td
|
||||
colSpan={
|
||||
calculatePercent
|
||||
? percentMode === "bakers"
|
||||
? 7
|
||||
: 6
|
||||
: 5
|
||||
}
|
||||
>
|
||||
<div class="component-note-content">
|
||||
<input
|
||||
class="component-notes"
|
||||
aria-label={`${component.name} note ${
|
||||
noteIndex + 1
|
||||
}`}
|
||||
value={note}
|
||||
placeholder="Add section note"
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
notes: (value.notes ?? []).map((entry, index) =>
|
||||
index === noteIndex
|
||||
? event.currentTarget.value
|
||||
: entry
|
||||
return (
|
||||
<div class="component-section-block" key={component.id}>
|
||||
<div class="table-wrap">
|
||||
<table
|
||||
class={`editor-items${
|
||||
calculatePercent && percentMode === "bakers"
|
||||
? " bakers-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{componentIndex === 0 && (
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="quantity-column">Qty</th>
|
||||
<th class="unit-column">Unit</th>
|
||||
<th class="entity-column">Ingredient / Recipe</th>
|
||||
<th class="notes-column">Notes</th>
|
||||
{calculatePercent && percentMode === "bakers" && (
|
||||
<th class="base-column">Base</th>
|
||||
)}
|
||||
{calculatePercent && <th class="percent-column">%</th>}
|
||||
<th class="actions-column"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{(data.components.length > 1 ||
|
||||
Boolean(component.name?.trim())) && (
|
||||
<tr
|
||||
class={`component-header-row${
|
||||
dragging?.kind === "component" &&
|
||||
dragging.index === componentIndex
|
||||
? " dragging"
|
||||
: ""
|
||||
}`}
|
||||
key={`header-${component.id}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragging?.kind === "component")
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: move(
|
||||
current.components,
|
||||
dragging.index,
|
||||
componentIndex
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-row"
|
||||
aria-label="Remove note"
|
||||
onClick={() =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
notes: (value.notes ?? []).filter(
|
||||
(_, index) => index !== noteIndex
|
||||
),
|
||||
}))
|
||||
}));
|
||||
setDragging(undefined);
|
||||
}}
|
||||
>
|
||||
<td
|
||||
colSpan={
|
||||
calculatePercent
|
||||
? percentMode === "bakers"
|
||||
? 7
|
||||
: 6
|
||||
: 5
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<div class="component-header-content">
|
||||
<input
|
||||
class="component-name-input"
|
||||
aria-label="Component name"
|
||||
placeholder="Header Name"
|
||||
value={component.name}
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
name: event.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div class="component-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-component-btn"
|
||||
title="Delete section"
|
||||
aria-label="Delete section"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: current.components.filter(
|
||||
(_, index) => index !== componentIndex
|
||||
),
|
||||
}))
|
||||
}
|
||||
disabled={data.components.length <= 1}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder section"
|
||||
aria-label="Drag to reorder section"
|
||||
draggable
|
||||
onDragStart={() =>
|
||||
setDragging({
|
||||
kind: "component",
|
||||
index: componentIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{component.items.map((item, itemIndex) => (
|
||||
<RecipeItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
componentIndex={componentIndex}
|
||||
itemIndex={itemIndex}
|
||||
units={units}
|
||||
ingredients={ingredients}
|
||||
pendingIngredients={pendingIngredients}
|
||||
recipes={recipes}
|
||||
recipeId={recipeId}
|
||||
dragging={dragging}
|
||||
calculatePercent={calculatePercent}
|
||||
percentMode={percentMode}
|
||||
percentValue={percent(item)}
|
||||
focusedItem={focusedItem}
|
||||
rowQueryValue={rowQuery[item.id]}
|
||||
onUpdateQuantity={(quantity) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, quantity } : line
|
||||
),
|
||||
}))
|
||||
}
|
||||
onUpdateUnit={(unitId) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, unit_id: unitId } : line
|
||||
),
|
||||
}))
|
||||
}
|
||||
onUpdateNotes={(notes) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, notes } : line
|
||||
),
|
||||
}))
|
||||
}
|
||||
onUpdateBasis={(basis) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? { ...line, basis_member: basis }
|
||||
: line
|
||||
),
|
||||
}))
|
||||
}
|
||||
onRemove={() =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.filter(
|
||||
(_, index) => index !== itemIndex
|
||||
),
|
||||
}))
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
setDragging({
|
||||
kind: "item",
|
||||
component: componentIndex,
|
||||
index: itemIndex,
|
||||
});
|
||||
{(component.notes ?? []).map((note, noteIndex) => (
|
||||
<tr
|
||||
class="component-note-row"
|
||||
key={`note-${component.id}-${noteIndex}`}
|
||||
>
|
||||
<td
|
||||
colSpan={
|
||||
calculatePercent
|
||||
? percentMode === "bakers"
|
||||
? 7
|
||||
: 6
|
||||
: 5
|
||||
}
|
||||
>
|
||||
<div class="component-note-content">
|
||||
<textarea
|
||||
class="component-notes"
|
||||
data-component-note={componentId}
|
||||
aria-label={`${component.name} note ${
|
||||
noteIndex + 1
|
||||
}`}
|
||||
value={note}
|
||||
placeholder="Add section note"
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
notes: (value.notes ?? []).map((entry, index) =>
|
||||
index === noteIndex
|
||||
? event.currentTarget.value
|
||||
: entry
|
||||
),
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-row"
|
||||
aria-label="Remove note"
|
||||
onClick={() =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
notes: (value.notes ?? []).filter(
|
||||
(_, index) => index !== noteIndex
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{component.items.map((item, itemIndex) => (
|
||||
<RecipeItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
componentIndex={componentIndex}
|
||||
itemIndex={itemIndex}
|
||||
units={units}
|
||||
ingredients={ingredients}
|
||||
pendingIngredients={pendingIngredients}
|
||||
recipes={recipes}
|
||||
recipeId={recipeId}
|
||||
dragging={dragging}
|
||||
calculatePercent={calculatePercent}
|
||||
percentMode={percentMode}
|
||||
percentValue={percent(item)}
|
||||
focusedItem={focusedItem}
|
||||
rowQueryValue={rowQuery[item.id]}
|
||||
onUpdateQuantity={(quantity) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, quantity } : line
|
||||
),
|
||||
}))
|
||||
}
|
||||
onUpdateUnit={(unitId) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, unit_id: unitId } : line
|
||||
),
|
||||
}))
|
||||
}
|
||||
onUpdateNotes={(notes) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, notes } : line
|
||||
),
|
||||
}))
|
||||
}
|
||||
onUpdateBasis={(basis) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? { ...line, basis_member: basis }
|
||||
: line
|
||||
),
|
||||
}))
|
||||
}
|
||||
onRemove={() =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.filter(
|
||||
(_, index) => index !== itemIndex
|
||||
),
|
||||
}))
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
setDragging({
|
||||
kind: "item",
|
||||
component: componentIndex,
|
||||
index: itemIndex,
|
||||
});
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.stopPropagation();
|
||||
if (
|
||||
dragging?.kind === "item" &&
|
||||
dragging.component === componentIndex
|
||||
) {
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: move(
|
||||
value.items,
|
||||
dragging.index,
|
||||
itemIndex
|
||||
),
|
||||
}));
|
||||
}
|
||||
setDragging(undefined);
|
||||
}}
|
||||
onFocus={() => setFocusedItem(item.id)}
|
||||
onBlur={() =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setFocusedItem((current) =>
|
||||
current === item.id ? undefined : current
|
||||
),
|
||||
120
|
||||
)
|
||||
}
|
||||
onQueryChange={(val) =>
|
||||
setRowQuery((current) => ({
|
||||
...current,
|
||||
[item.id]: val,
|
||||
}))
|
||||
}
|
||||
onSelectChoice={(kind, id, name) =>
|
||||
replaceLineReference(
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
kind,
|
||||
id,
|
||||
name
|
||||
)
|
||||
}
|
||||
onCreatePendingIngredient={(name) =>
|
||||
createPendingRowIngredient(
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
name
|
||||
)
|
||||
}
|
||||
onSetPercent={(target) =>
|
||||
setPercent(componentIndex, itemIndex, target)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="quick-add-bar">
|
||||
<div class="quick-add-search">
|
||||
<input
|
||||
value={currentQuery}
|
||||
placeholder="1cup onion sliced"
|
||||
aria-label={`Add ingredient or recipe to ${component.name || "section"}`}
|
||||
onFocus={() => setFocusedQuickAdd(componentId)}
|
||||
onBlur={() => window.setTimeout(() => setFocusedQuickAdd((current) => current === componentId ? undefined : current), 120)}
|
||||
onInput={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setQuery((current) => ({ ...current, [componentId]: value }));
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.stopPropagation();
|
||||
if (
|
||||
dragging?.kind === "item" &&
|
||||
dragging.component === componentIndex
|
||||
) {
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: move(
|
||||
value.items,
|
||||
dragging.index,
|
||||
itemIndex
|
||||
),
|
||||
}));
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
document.getElementById(`quick-add-note-${componentId}`)?.focus();
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const val = (query[componentId] ?? "").trim();
|
||||
if (val) {
|
||||
const choices = [
|
||||
...[...ingredients, ...pendingIngredients].map((item) => ({ ...item, kind: "ingredient" as const })),
|
||||
...[...recipes, ...pendingRecipes].filter((item) => item.id !== recipeId).map((item) => ({ ...item, kind: "recipe" as const })),
|
||||
].filter((item) => [item.name, ...(item.aliases ?? [])].some((name) => name.toLowerCase().includes(val.toLowerCase())));
|
||||
const choice = choices.find((item) => normal(item.name) === normal(val)) ?? choices[0];
|
||||
if (choice) addLine(componentIndex, `${choice.kind}:${choice.id}`);
|
||||
else createPendingIngredient(componentIndex, val);
|
||||
setQuery((current) => ({ ...current, [componentId]: "" }));
|
||||
}
|
||||
}
|
||||
setDragging(undefined);
|
||||
}}
|
||||
onFocus={() => setFocusedItem(item.id)}
|
||||
onBlur={() =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setFocusedItem((current) =>
|
||||
current === item.id ? undefined : current
|
||||
),
|
||||
120
|
||||
)
|
||||
}
|
||||
onQueryChange={(val) =>
|
||||
setRowQuery((current) => ({
|
||||
...current,
|
||||
[item.id]: val,
|
||||
}))
|
||||
}
|
||||
onSelectChoice={(kind, id, name) =>
|
||||
replaceLineReference(
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
kind,
|
||||
id,
|
||||
name
|
||||
)
|
||||
}
|
||||
onCreatePendingIngredient={(name) =>
|
||||
createPendingRowIngredient(
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
name
|
||||
)
|
||||
}
|
||||
onSetPercent={(target) =>
|
||||
setPercent(componentIndex, itemIndex, target)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="quick-add-bar">
|
||||
<div class="quick-add-search">
|
||||
<input
|
||||
value={
|
||||
query[
|
||||
data.components[data.components.length - 1]?.id ??
|
||||
data.components[0]?.id ??
|
||||
""
|
||||
] ?? ""
|
||||
}
|
||||
placeholder="1cup onion sliced"
|
||||
aria-label="Add ingredient or recipe"
|
||||
onInput={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
const activeId =
|
||||
data.components[data.components.length - 1]?.id ??
|
||||
data.components[0]?.id;
|
||||
if (activeId)
|
||||
setQuery((current) => ({ ...current, [activeId]: value }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const activeIndex = data.components.length - 1;
|
||||
const activeId =
|
||||
data.components[activeIndex]?.id ??
|
||||
data.components[0]?.id;
|
||||
const val = (query[activeId ?? ""] ?? "").trim();
|
||||
if (val) {
|
||||
createPendingIngredient(activeIndex, val);
|
||||
setQuery((current) => ({ ...current, [activeId]: "" }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{Boolean(
|
||||
(
|
||||
query[
|
||||
data.components[data.components.length - 1]?.id ??
|
||||
data.components[0]?.id ??
|
||||
""
|
||||
] ?? ""
|
||||
).trim()
|
||||
) && (
|
||||
<div class="recipe-item-results">
|
||||
{[
|
||||
...[...ingredients, ...pendingIngredients].map((item) => ({
|
||||
...item,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...recipes
|
||||
.filter((item) => item.id !== recipeId)
|
||||
.map((item) => ({ ...item, kind: "recipe" as const })),
|
||||
]
|
||||
.filter((item) => {
|
||||
const activeId =
|
||||
data.components[data.components.length - 1]?.id ??
|
||||
data.components[0]?.id ??
|
||||
"";
|
||||
const q = (query[activeId] ?? "").trim().toLowerCase();
|
||||
return [item.name, ...(item.aliases ?? [])].some((name) =>
|
||||
name.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.kind}-${item.id}`}
|
||||
onClick={() => {
|
||||
const activeIndex = data.components.length - 1;
|
||||
const activeId =
|
||||
data.components[activeIndex]?.id ??
|
||||
data.components[0]?.id ??
|
||||
"";
|
||||
addLine(activeIndex, `${item.kind}:${item.id}`);
|
||||
setQuery((current) => ({ ...current, [activeId]: "" }));
|
||||
{Boolean(currentQuery.trim()) && (
|
||||
<div class="recipe-item-results">
|
||||
{[
|
||||
...[...ingredients, ...pendingIngredients].map((item) => ({
|
||||
...item,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...[...recipes, ...pendingRecipes]
|
||||
.filter((item) => item.id !== recipeId)
|
||||
.map((item) => ({ ...item, kind: "recipe" as const })),
|
||||
]
|
||||
.filter((item) => {
|
||||
const q = currentQuery.trim().toLowerCase();
|
||||
return [item.name, ...(item.aliases ?? [])].some((name) =>
|
||||
name.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.kind}-${item.id}`}
|
||||
onClick={() => {
|
||||
addLine(componentIndex, `${item.kind}:${item.id}`);
|
||||
setQuery((current) => ({ ...current, [componentId]: "" }));
|
||||
}}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<small>
|
||||
{item.kind === "recipe" ? "Recipe" : "Ingredient"}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
{(() => {
|
||||
const q = currentQuery.trim();
|
||||
if (
|
||||
q &&
|
||||
!ingredients.some(
|
||||
(item) => normal(item.name) === normal(q)
|
||||
) &&
|
||||
!pendingIngredients.some(
|
||||
(item) => normal(item.name) === normal(q)
|
||||
)
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="create-ingredient-result"
|
||||
onClick={() => {
|
||||
createPendingIngredient(componentIndex, q);
|
||||
setQuery((current) => ({
|
||||
...current,
|
||||
[componentId]: "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<strong>+ Create “{q}”</strong>
|
||||
<small>New ingredient</small>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{(() => {
|
||||
const q = currentQuery.trim();
|
||||
if (q && !recipes.some((item) => normal(item.name) === normal(q)) && !pendingRecipes.some((item) => normal(item.name) === normal(q))) {
|
||||
return <button type="button" class="create-recipe-result" onClick={() => { createPendingRecipe(componentIndex, q); setQuery((current) => ({ ...current, [componentId]: "" })); }}><strong>+ Create “{q}”</strong><small>New recipe</small></button>;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="quick-add-notes">
|
||||
<textarea
|
||||
id={`quick-add-note-${componentId}`}
|
||||
value={currentDraftNote}
|
||||
placeholder="Add notes"
|
||||
aria-label="Add notes"
|
||||
onInput={(event) => {
|
||||
const val = event.currentTarget.value;
|
||||
setDraftNotes((current) => ({
|
||||
...current,
|
||||
[componentId]: val,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<small>
|
||||
{item.kind === "recipe" ? "Recipe" : "Ingredient"}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
{(() => {
|
||||
const activeId =
|
||||
data.components[data.components.length - 1]?.id ??
|
||||
data.components[0]?.id ??
|
||||
"";
|
||||
const q = query[activeId] ?? "";
|
||||
if (
|
||||
!ingredients.some(
|
||||
(item) => normal(item.name) === normal(q)
|
||||
) &&
|
||||
!pendingIngredients.some(
|
||||
(item) => normal(item.name) === normal(q)
|
||||
)
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="create-ingredient-result"
|
||||
onClick={() => {
|
||||
const activeIndex = data.components.length - 1;
|
||||
createPendingIngredient(activeIndex, q);
|
||||
setQuery((current) => ({
|
||||
...current,
|
||||
[activeId]: "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<strong>+ Create “{q.trim()}”</strong>
|
||||
<small>New ingredient</small>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="quick-add-notes">
|
||||
<input placeholder="Add notes" aria-label="Add notes" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="structure-bottom-bar">
|
||||
<div class="editor-keyboard-hint">
|
||||
{focusedQuickAdd && <div class="editor-keyboard-hint">
|
||||
<span>
|
||||
<kbd>Enter</kbd> to complete
|
||||
</span>
|
||||
<span>
|
||||
<kbd>Tab</kbd> to add a note
|
||||
</span>
|
||||
</div>
|
||||
</div>}
|
||||
<div class="structure-add-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
const id = uid("component");
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: [
|
||||
...current.components,
|
||||
{
|
||||
id: uid("component"),
|
||||
id,
|
||||
name: "",
|
||||
items: [],
|
||||
notes: [],
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
window.requestAnimationFrame(() =>
|
||||
document.querySelector<HTMLInputElement>(
|
||||
`.component-section-block:last-child .component-name-input`
|
||||
)?.focus()
|
||||
);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path
|
||||
@@ -587,17 +620,20 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
let targetId = "";
|
||||
setData((current) => {
|
||||
if (current.components.length === 0) {
|
||||
targetId = uid("component");
|
||||
return {
|
||||
...current,
|
||||
components: [
|
||||
{ id: uid("component"), name: "", items: [], notes: [""] },
|
||||
{ id: targetId, name: "", items: [], notes: [""] },
|
||||
],
|
||||
};
|
||||
}
|
||||
const lastIndex = current.components.length - 1;
|
||||
targetId = current.components[lastIndex].id;
|
||||
return {
|
||||
...current,
|
||||
components: current.components.map((component, index) =>
|
||||
@@ -609,8 +645,14 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
: component
|
||||
),
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
window.requestAnimationFrame(() => {
|
||||
const notes = document.querySelectorAll<HTMLTextAreaElement>(
|
||||
`[data-component-note="${targetId}"]`
|
||||
);
|
||||
notes.item(notes.length - 1)?.focus();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path
|
||||
|
||||
@@ -73,7 +73,6 @@ export default function WorkspaceFilterBar({
|
||||
const [openChipId, setOpenChipId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Mobile detection state
|
||||
const [activeMobileCatId, setActiveMobileCatId] = useState<string | null>(null);
|
||||
const [menuAlignRight, setMenuAlignRight] = useState(false);
|
||||
const [chipAlignRight, setChipAlignRight] = useState(false);
|
||||
@@ -81,7 +80,6 @@ export default function WorkspaceFilterBar({
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close menus when clicking outside (desktop)
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (window.innerWidth > 980) {
|
||||
@@ -109,11 +107,8 @@ export default function WorkspaceFilterBar({
|
||||
setMenuOpen(!menuOpen);
|
||||
};
|
||||
|
||||
// Parse active filter parameters
|
||||
const isRecipe = type === "recipe";
|
||||
const isIngredient = type === "ingredient";
|
||||
const isGlobalSearch = !type || Boolean(query);
|
||||
|
||||
const emptyRecipeActive = currentParams["empty_recipe"] === "1";
|
||||
const missingYieldActive = currentParams["missing_yield"] === "1";
|
||||
const placeholderStepsActive = currentParams["placeholder_steps"] === "1";
|
||||
@@ -128,19 +123,6 @@ export default function WorkspaceFilterBar({
|
||||
? [currentParams["item_type"] as string]
|
||||
: [];
|
||||
|
||||
const selectedTags = Array.isArray(currentParams["tag"])
|
||||
? currentParams["tag"]
|
||||
: currentParams["tag"]
|
||||
? [currentParams["tag"] as string]
|
||||
: [];
|
||||
|
||||
const selectedIngredients = Array.isArray(currentParams["ingredient"])
|
||||
? currentParams["ingredient"]
|
||||
: currentParams["ingredient"]
|
||||
? [currentParams["ingredient"] as string]
|
||||
: [];
|
||||
|
||||
// Determine configured categories
|
||||
const categories: FilterCategoryConfig[] = [];
|
||||
|
||||
if (isRecipe) {
|
||||
@@ -184,7 +166,6 @@ export default function WorkspaceFilterBar({
|
||||
categories.push({ id: "ingredients", label: "Ingredients", icon: ICONS.ingredient, options: ingredientOptions, emptyMessage: "No additional filter options available." });
|
||||
}
|
||||
|
||||
// Helper to check if a category has active selected options
|
||||
const hasActiveOptions = (cat: FilterCategoryConfig) => {
|
||||
return cat.options.some(opt => opt.checked);
|
||||
};
|
||||
@@ -192,7 +173,6 @@ export default function WorkspaceFilterBar({
|
||||
const categoriesWithActiveSelections = categories.filter(c => hasActiveOptions(c));
|
||||
const totalActiveCount = categoriesWithActiveSelections.length;
|
||||
|
||||
// The chips visible on screen
|
||||
const visibleCategoryIds = Array.from(
|
||||
new Set([
|
||||
...categoriesWithActiveSelections.map(c => c.id),
|
||||
@@ -306,7 +286,7 @@ export default function WorkspaceFilterBar({
|
||||
|
||||
return (
|
||||
<div class="workspace-filter-system">
|
||||
{/* 1. Main Filter Button Trigger in Pills Row */}
|
||||
|
||||
<div class="workspace-filter-trigger-wrapper" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -323,7 +303,7 @@ export default function WorkspaceFilterBar({
|
||||
<span>Filter{totalActiveCount > 0 ? ` (${totalActiveCount})` : ""}</span>
|
||||
</button>
|
||||
|
||||
{/* Desktop Filter Categories Menu */}
|
||||
|
||||
{menuOpen && (
|
||||
<div class={`workspace-filter-menu-popover desktop-only-filter-popover ${menuAlignRight ? "align-right" : "align-left"}`} role="menu">
|
||||
<div class="filter-menu-header">
|
||||
@@ -371,7 +351,7 @@ export default function WorkspaceFilterBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Mobile Filter & Sort Modal */}
|
||||
|
||||
{menuOpen && typeof document !== "undefined" && createPortal(
|
||||
<div class="mobile-filter-backdrop" role="dialog" aria-modal="true" aria-label="Filter and Sort">
|
||||
<div class="mobile-filter-sheet">
|
||||
@@ -411,7 +391,6 @@ export default function WorkspaceFilterBar({
|
||||
|
||||
<div class="mobile-filter-body">
|
||||
{activeMobileCat ? (
|
||||
/* Category Options View */
|
||||
<div class="mobile-filter-category-view">
|
||||
{activeMobileCat.options.length > 0 ? (
|
||||
<>
|
||||
@@ -456,7 +435,6 @@ export default function WorkspaceFilterBar({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Main Filter & Sort Menu */
|
||||
<div class="mobile-filter-main-view">
|
||||
<div class="mobile-filter-section-heading">
|
||||
<svg viewBox="0 0 18 21" aria-hidden="true" class="mobile-section-icon">
|
||||
@@ -517,7 +495,7 @@ export default function WorkspaceFilterBar({
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 3. Secondary Active Filter Rule Chips Bar (Horizontal Scroll on Mobile) */}
|
||||
|
||||
{visibleCategoryIds.length > 0 && (
|
||||
<div class="filter-rules-row" role="region" aria-label="Active filters">
|
||||
{visibleCategoryIds.map((catId, index) => {
|
||||
@@ -564,7 +542,7 @@ export default function WorkspaceFilterBar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Popover for Active Chip (Desktop) */}
|
||||
|
||||
{isOpen && (
|
||||
<div class={`filter-chip-popover desktop-only-filter-popover ${chipAlignRight ? "align-right" : "align-left"}`} ref={popoverRef}>
|
||||
{cat.options.length > 0 ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState, Option, PercentMode } from "./types";
|
||||
import { normal } from "./utils";
|
||||
|
||||
type Props = {
|
||||
item: RecipeStructure["components"][number]["items"][number];
|
||||
@@ -36,10 +37,6 @@ type Props = {
|
||||
onSetPercent: (target: number) => void;
|
||||
};
|
||||
|
||||
const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
/** Commit a number input's value, returning the parsed number or undefined. */
|
||||
const commitNumericInput = (input: HTMLInputElement): number | undefined => {
|
||||
const num = Number(input.value);
|
||||
return Number.isFinite(num) ? num : undefined;
|
||||
@@ -261,11 +258,12 @@ export default function RecipeItemRow({
|
||||
)}
|
||||
</td>
|
||||
<td class="notes-column">
|
||||
<input
|
||||
<textarea
|
||||
class="line-notes"
|
||||
aria-label={`${label} notes`}
|
||||
value={item.notes ?? ""}
|
||||
placeholder="Add Notes"
|
||||
rows={1}
|
||||
onInput={(event) => onUpdateNotes(event.currentTarget.value)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createPortal } from "preact/compat";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState } from "./types";
|
||||
import { uid } from "./utils";
|
||||
|
||||
type Props = {
|
||||
steps: RecipeStructure["steps"];
|
||||
@@ -12,8 +13,6 @@ type Props = {
|
||||
onOpenBulkPrep: () => void;
|
||||
};
|
||||
|
||||
const uid = (prefix: string) => `${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export default function RecipeMethodEditor({
|
||||
steps,
|
||||
methodTarget,
|
||||
@@ -23,18 +22,37 @@ export default function RecipeMethodEditor({
|
||||
onDrop,
|
||||
onOpenBulkPrep,
|
||||
}: Props) {
|
||||
let stepCount = 0;
|
||||
|
||||
const content = (
|
||||
<section class="method-editor">
|
||||
<div class="method-header-row">
|
||||
<h2>
|
||||
Prep Method <small>{steps.length}</small>
|
||||
Prep Method{" "}
|
||||
<small>
|
||||
{steps.filter(
|
||||
(s) =>
|
||||
!s.instruction.trim().endsWith(":") &&
|
||||
!/^\(.+\)$/.test(s.instruction.trim())
|
||||
).length || steps.length}
|
||||
</small>
|
||||
</h2>
|
||||
</div>
|
||||
<ol class="method-steps-list">
|
||||
{steps.map((step, index) => {
|
||||
const stepKind = step.instruction.trim().endsWith(":")
|
||||
const text = step.instruction.trim();
|
||||
const isHeading = text.endsWith(":") || /^#+\s+/.test(text);
|
||||
const isNote = /^\(.+\)$/.test(text) || /^note:\s*/i.test(text);
|
||||
|
||||
let currentStepNum: number | null = null;
|
||||
if (!isHeading && !isNote) {
|
||||
stepCount += 1;
|
||||
currentStepNum = stepCount;
|
||||
}
|
||||
|
||||
const stepKindClass = isHeading
|
||||
? " prep-heading"
|
||||
: /^\(.+\)$/.test(step.instruction.trim())
|
||||
: isNote
|
||||
? " prep-note"
|
||||
: "";
|
||||
|
||||
@@ -44,19 +62,37 @@ export default function RecipeMethodEditor({
|
||||
dragging?.kind === "step" && dragging.index === index
|
||||
? " dragging"
|
||||
: ""
|
||||
}${stepKind}`}
|
||||
}${stepKindClass}`}
|
||||
key={step.id}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragging?.kind === "step") onDrop(dragging.index, index);
|
||||
}}
|
||||
>
|
||||
<span class="step-num">{index + 1}.</span>
|
||||
<div class="step-indicator">
|
||||
{isHeading ? (
|
||||
<span class="prep-heading-tag" title="Section Header">
|
||||
H
|
||||
</span>
|
||||
) : isNote ? (
|
||||
<span class="prep-note-tag" title="Prep Note">
|
||||
•
|
||||
</span>
|
||||
) : (
|
||||
<span class="step-num">{currentStepNum}.</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<textarea
|
||||
rows={2}
|
||||
rows={isHeading ? 1 : 2}
|
||||
class="step-textarea"
|
||||
placeholder="Add Prep Step"
|
||||
placeholder={
|
||||
isHeading
|
||||
? "Section Header (e.g. To Sear:)"
|
||||
: isNote
|
||||
? "Prep Note (e.g. Can be prepared in advance)"
|
||||
: "Add Prep Step"
|
||||
}
|
||||
value={step.instruction}
|
||||
onInput={(event) =>
|
||||
onStepsChange(
|
||||
@@ -117,6 +153,21 @@ export default function RecipeMethodEditor({
|
||||
})}
|
||||
</ol>
|
||||
<div class="method-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onStepsChange([
|
||||
...steps,
|
||||
{ id: uid("step"), instruction: "", equipment_ids: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
Add Step
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export * from "./useRecipeStructure";
|
||||
export { default as RecipeItemRow } from "./RecipeItemRow";
|
||||
export { default as RecipeMethodEditor } from "./RecipeMethodEditor";
|
||||
|
||||
@@ -8,12 +8,8 @@ import type {
|
||||
PercentMode,
|
||||
RecipeEditorProps,
|
||||
} from "./types";
|
||||
|
||||
export const uid = (prefix: string) =>
|
||||
`${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
import { newRecipeId } from "../../lib/recipe-id";
|
||||
import { normal, uid } from "./utils";
|
||||
|
||||
const unitAliases: Record<string, string> = {
|
||||
g: "gram", gram: "gram", grams: "gram", kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
|
||||
@@ -45,6 +41,7 @@ export function useRecipeStructure({
|
||||
const [prepText, setPrepText] = useState("");
|
||||
const [prepError, setPrepError] = useState("");
|
||||
const [pendingIngredients, setPendingIngredients] = useState<Option[]>([]);
|
||||
const [pendingRecipes, setPendingRecipes] = useState<Option[]>([]);
|
||||
const [methodTarget, setMethodTarget] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,10 +92,14 @@ export function useRecipeStructure({
|
||||
};
|
||||
|
||||
const calculatedYieldWeight = () =>
|
||||
allItems().reduce((sum, item) => sum + (weight(item) ?? 0), 0);
|
||||
allItems().filter((item) => !item.optional).reduce(
|
||||
(sum, item) =>
|
||||
sum + (weight(item) ?? 0) * (item.nutrition_retention_factor ?? 1),
|
||||
0
|
||||
);
|
||||
|
||||
const unconvertedYieldItems = () =>
|
||||
allItems().filter((item) => weight(item) == null).length;
|
||||
allItems().filter((item) => !item.optional && weight(item) == null).length;
|
||||
|
||||
const denominator = () =>
|
||||
percentMode === "standard"
|
||||
@@ -247,6 +248,23 @@ export function useRecipeStructure({
|
||||
addLine(componentIndex, `ingredient:${id}`);
|
||||
};
|
||||
|
||||
const createPendingRecipe = (componentIndex: number, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const existing = [...recipes, ...pendingRecipes].find(
|
||||
(recipe) => normal(recipe.name) === normal(trimmed)
|
||||
);
|
||||
if (existing) {
|
||||
addLine(componentIndex, `recipe:${existing.id}`);
|
||||
return;
|
||||
}
|
||||
const takenIds = new Set([...recipes, ...pendingRecipes].map((recipe) => recipe.id));
|
||||
let id = newRecipeId();
|
||||
while (takenIds.has(id) || id === recipeId) id = newRecipeId();
|
||||
setPendingRecipes((current) => [...current, { id, name: trimmed }]);
|
||||
addLine(componentIndex, `recipe:${id}`);
|
||||
};
|
||||
|
||||
const identityTokens = (value: string) =>
|
||||
normal(value)
|
||||
.split(" ")
|
||||
@@ -453,11 +471,24 @@ export function useRecipeStructure({
|
||||
setPrepError("Field is required");
|
||||
return;
|
||||
}
|
||||
const additions = lines.map((instruction) => ({
|
||||
id: uid("step"),
|
||||
instruction,
|
||||
equipment_ids: [],
|
||||
}));
|
||||
const additions = lines.map((rawLine) => {
|
||||
let instruction = rawLine.trim();
|
||||
const isHeader = instruction.endsWith(":") || /^#+\s+/.test(instruction);
|
||||
if (isHeader) {
|
||||
instruction = `${instruction.replace(/^#+\s*/, "").replace(/:$/, "").trim()}:`;
|
||||
} else {
|
||||
instruction = instruction
|
||||
.replace(/^\s*(?:step\s+)?\d+[\.\)\:\-]\s*/i, "")
|
||||
.replace(/^\s*\[\d+\]\s*/, "")
|
||||
.replace(/^\s*[•\-\*]\s*/, "")
|
||||
.trim();
|
||||
}
|
||||
return {
|
||||
id: uid("step"),
|
||||
instruction,
|
||||
equipment_ids: [],
|
||||
};
|
||||
});
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps:
|
||||
@@ -480,7 +511,9 @@ export function useRecipeStructure({
|
||||
? [...ingredients, ...pendingIngredients].find(
|
||||
(entry) => entry.id === item.ingredient_id
|
||||
)?.name
|
||||
: recipes.find((entry) => entry.id === item.subrecipe_id)?.name;
|
||||
: [...recipes, ...pendingRecipes].find(
|
||||
(entry) => entry.id === item.subrecipe_id
|
||||
)?.name;
|
||||
return normal(typed) !== normal(currentLabel ?? "");
|
||||
});
|
||||
if (unresolvedRow) {
|
||||
@@ -499,7 +532,6 @@ export function useRecipeStructure({
|
||||
"#recipe-additional-form"
|
||||
);
|
||||
const additional = additionalForm ? new FormData(additionalForm) : undefined;
|
||||
const servingsText = String(details?.get("yield_servings") ?? "").trim();
|
||||
const shelfQuantityText = String(
|
||||
additional?.get("shelf_quantity") ?? ""
|
||||
).trim();
|
||||
@@ -507,13 +539,14 @@ export function useRecipeStructure({
|
||||
const payload = {
|
||||
...data,
|
||||
new_ingredients: pendingIngredients.map(({ id, name }) => ({ id, name })),
|
||||
new_recipes: pendingRecipes.map(({ id, name }) => ({ id, name })),
|
||||
...(details
|
||||
? {
|
||||
metadata: {
|
||||
title: String(details.get("title") ?? "").trim(),
|
||||
yield_quantity: Number(details.get("yield_quantity")),
|
||||
yield_unit_id: String(details.get("yield_unit_id") ?? ""),
|
||||
yield_servings: servingsText ? Number(servingsText) : null,
|
||||
yield_servings: null,
|
||||
yield_basis:
|
||||
String(details.get("yield_basis") ?? "").trim() || null,
|
||||
station: String(additional?.get("station") ?? "").trim() || null,
|
||||
@@ -558,6 +591,7 @@ export function useRecipeStructure({
|
||||
}
|
||||
setData(result);
|
||||
setPendingIngredients([]);
|
||||
setPendingRecipes([]);
|
||||
setBaseline(JSON.stringify(result));
|
||||
const saveVersionInput = document.querySelector<HTMLInputElement>(
|
||||
'#recipe-details-form input[name="save_version"]'
|
||||
@@ -579,7 +613,7 @@ export function useRecipeStructure({
|
||||
document.addEventListener("recipe:save-structure", handleDone);
|
||||
return () =>
|
||||
document.removeEventListener("recipe:save-structure", handleDone);
|
||||
}, [data, pendingIngredients, calculatePercent, percentMode]);
|
||||
}, [data, pendingIngredients, pendingRecipes, calculatePercent, percentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.dispatchEvent(new CustomEvent("recipe:editor-ready"));
|
||||
@@ -606,10 +640,10 @@ export function useRecipeStructure({
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("recipe:dirty", {
|
||||
detail:
|
||||
JSON.stringify(data) !== baseline || pendingIngredients.length > 0,
|
||||
JSON.stringify(data) !== baseline || pendingIngredients.length > 0 || pendingRecipes.length > 0,
|
||||
})
|
||||
);
|
||||
}, [data, baseline, pendingIngredients]);
|
||||
}, [data, baseline, pendingIngredients, pendingRecipes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoYield) return;
|
||||
@@ -650,10 +684,12 @@ export function useRecipeStructure({
|
||||
prepError,
|
||||
setPrepError,
|
||||
pendingIngredients,
|
||||
pendingRecipes,
|
||||
methodTarget,
|
||||
baseline,
|
||||
state,
|
||||
message,
|
||||
setMessage,
|
||||
dragging,
|
||||
setDragging,
|
||||
calculatePercent,
|
||||
@@ -668,6 +704,7 @@ export function useRecipeStructure({
|
||||
replaceLineReference,
|
||||
createPendingRowIngredient,
|
||||
createPendingIngredient,
|
||||
createPendingRecipe,
|
||||
addBulkIngredients,
|
||||
addBulkPrepSteps,
|
||||
save,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const uid = (prefix: string) => `${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export const normal = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
@@ -59,6 +59,17 @@ const isApplication = Astro.url.pathname.startsWith("/app") || Astro.url.pathnam
|
||||
menus().forEach((menu) => menu.addEventListener("toggle", () => {
|
||||
if (menu instanceof HTMLDetailsElement && menu.open) close(menu);
|
||||
}));
|
||||
|
||||
document.querySelectorAll(".notice, .success-notice, .entity-notice, .book-notice, .count-notice").forEach((el) => {
|
||||
if (el.querySelector(".notice-dismiss-btn")) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "notice-dismiss-btn";
|
||||
btn.textContent = "×";
|
||||
btn.ariaLabel = "Dismiss notice";
|
||||
btn.onclick = () => el.remove();
|
||||
el.appendChild(btn);
|
||||
});
|
||||
})();
|
||||
</script>}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function apiError(error: unknown, fallback: string, status = 500): Response {
|
||||
return Response.json({ success: false, error: error instanceof Error ? error.message : fallback }, { status });
|
||||
}
|
||||
@@ -1,23 +1,10 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { exportDatabase } from "./export-database";
|
||||
import { importDatabase } from "./import-database";
|
||||
import { validateBackupBundle } from "./validate-backup";
|
||||
import type { FormulationBackupBundle } from "./types";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
const root = path.resolve(__dirname, "../../..");
|
||||
const migrationsDir = path.join(root, "migrations");
|
||||
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
||||
for (const file of files) {
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf8");
|
||||
db.exec(sql);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
import { createTestDatabase } from "../test-database";
|
||||
|
||||
describe("Database Backup & Restore System", () => {
|
||||
let db: DatabaseSync;
|
||||
@@ -25,7 +12,6 @@ describe("Database Backup & Restore System", () => {
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
|
||||
// Populate baseline sample data
|
||||
db.exec(`
|
||||
INSERT INTO units (id, name, symbol, dimension, system) VALUES
|
||||
('gram', 'Gram', 'g', 'mass', 'metric'),
|
||||
@@ -125,7 +111,6 @@ describe("Database Backup & Restore System", () => {
|
||||
const originalBundle = exportDatabase(db);
|
||||
|
||||
const targetDb = createTestDatabase();
|
||||
// Wipe target db completely
|
||||
targetDb.exec("DELETE FROM units;");
|
||||
|
||||
const result = importDatabase(targetDb, originalBundle, { mode: "replace", rebuildProjections: false });
|
||||
@@ -194,12 +179,10 @@ describe("Database Backup & Restore System", () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Attempt importing badBundle which should fail validation/execution
|
||||
expect(() => {
|
||||
importDatabase(targetDb, badBundle, { mode: "replace", rebuildProjections: false });
|
||||
}).toThrow();
|
||||
|
||||
// Verify existing units record remains intact
|
||||
const units = targetDb.prepare("SELECT id FROM units").all() as Array<{ id: string }>;
|
||||
expect(units.length).toBe(1);
|
||||
expect(units[0].id).toBe("gram");
|
||||
|
||||
@@ -15,22 +15,18 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
export function exportDatabase(database: DatabaseSync): FormulationBackupBundle {
|
||||
// 1. Units
|
||||
const units = database
|
||||
.prepare("SELECT * FROM units ORDER BY dimension, id")
|
||||
.all() as unknown as UnitBackupRecord[];
|
||||
|
||||
// 2. Equipment
|
||||
const equipment = database
|
||||
.prepare("SELECT * FROM equipment ORDER BY id")
|
||||
.all() as unknown as EquipmentBackupRecord[];
|
||||
|
||||
// 3. Prep Actions
|
||||
const prepActions = database
|
||||
.prepare("SELECT * FROM prep_actions ORDER BY id")
|
||||
.all() as unknown as PrepActionBackupRecord[];
|
||||
|
||||
// 4. Ingredients & sub-tables
|
||||
const aliasStmt = database.prepare(
|
||||
"SELECT name, kind FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name"
|
||||
);
|
||||
@@ -66,7 +62,6 @@ export function exportDatabase(database: DatabaseSync): FormulationBackupBundle
|
||||
density_measurements: ingredientDensityStmt.all(ing.id) as any[],
|
||||
}));
|
||||
|
||||
// 5. Recipes & sub-tables
|
||||
const componentStmt = database.prepare(
|
||||
"SELECT id, position, name, notes_json FROM recipe_components WHERE recipe_id = ? ORDER BY position"
|
||||
);
|
||||
@@ -148,7 +143,6 @@ export function exportDatabase(database: DatabaseSync): FormulationBackupBundle
|
||||
};
|
||||
});
|
||||
|
||||
// 6. Purchase items & price observations
|
||||
const priceStmt = database.prepare(
|
||||
"SELECT effective_at, currency, amount, source_json FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC"
|
||||
);
|
||||
@@ -181,12 +175,10 @@ export function exportDatabase(database: DatabaseSync): FormulationBackupBundle
|
||||
}
|
||||
);
|
||||
|
||||
// 7. Source mappings
|
||||
const source_mappings = database
|
||||
.prepare("SELECT * FROM source_mappings ORDER BY id")
|
||||
.all() as unknown as SourceMappingBackupRecord[];
|
||||
|
||||
// 8. Collections & collection recipes
|
||||
const collRecipeStmt = database.prepare(
|
||||
"SELECT recipe_id, position FROM collection_recipes WHERE collection_id = ? ORDER BY position"
|
||||
);
|
||||
@@ -205,7 +197,6 @@ export function exportDatabase(database: DatabaseSync): FormulationBackupBundle
|
||||
recipes: collRecipeStmt.all(coll.id) as any[],
|
||||
}));
|
||||
|
||||
// 9. Inventory locations
|
||||
let inventory_locations: InventoryLocationBackupRecord[] = [];
|
||||
try {
|
||||
inventory_locations = database
|
||||
@@ -213,7 +204,6 @@ export function exportDatabase(database: DatabaseSync): FormulationBackupBundle
|
||||
.all() as unknown as InventoryLocationBackupRecord[];
|
||||
} catch {}
|
||||
|
||||
// 10. Inventory counts & count items
|
||||
let inventory_counts: InventoryCountBackupRecord[] = [];
|
||||
try {
|
||||
const countItemStmt = database.prepare(
|
||||
|
||||
@@ -66,7 +66,6 @@ export function importDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Units
|
||||
const unitStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO units(id, name, symbol, dimension, system, base_unit_id, factor, offset) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
@@ -83,7 +82,6 @@ export function importDatabase(
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Equipment
|
||||
const eqStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO equipment(id, name, category, notes) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
@@ -91,7 +89,6 @@ export function importDatabase(
|
||||
eqStmt.run(eq.id, eq.name, eq.category ?? null, eq.notes ?? null);
|
||||
}
|
||||
|
||||
// 3. Prep Actions
|
||||
const prepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO prep_actions(id, name, action_type, default_yield_factor, notes) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
@@ -105,7 +102,6 @@ export function importDatabase(
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Ingredients & sub-tables
|
||||
const ingStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredients(id, schema_version, name, status, categories_json, tags_json, description, source_json, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
@@ -175,7 +171,6 @@ export function importDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Recipes & sub-tables
|
||||
const recStmt = database.prepare(
|
||||
`INSERT OR REPLACE INTO recipes(
|
||||
id, schema_version, save_version, title, summary,
|
||||
@@ -325,7 +320,6 @@ export function importDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Purchase items & prices
|
||||
const piStmt = database.prepare(
|
||||
`INSERT OR REPLACE INTO purchase_items(
|
||||
id, ingredient_id, name, brand, supplier_id,
|
||||
@@ -368,7 +362,6 @@ export function importDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Source mappings
|
||||
const smStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO source_mappings(id, subject_type, subject_id, mapping_type, status, source_json, nutrition_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
@@ -384,7 +377,6 @@ export function importDatabase(
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Collections & collection recipes
|
||||
const collStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO collections(id, name, description, source_json, deleted_at) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
@@ -410,7 +402,6 @@ export function importDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Inventory locations
|
||||
if (Array.isArray(data.inventory_locations) && data.inventory_locations.length > 0) {
|
||||
const locStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO inventory_locations(id, name, position, deleted_at) VALUES (?, ?, ?, ?)"
|
||||
@@ -420,7 +411,6 @@ export function importDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Inventory counts & count items
|
||||
if (Array.isArray(data.inventory_counts) && data.inventory_counts.length > 0) {
|
||||
const countStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO inventory_counts(id, title, counted_at, status, notes, created_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
|
||||
@@ -37,7 +37,6 @@ export function validateBackupBundle(input: unknown): ValidationResult {
|
||||
errors.push("Missing 'data.recipes' array in backup payload.");
|
||||
}
|
||||
|
||||
// Check unit integrity
|
||||
const unitIds = new Set<string>();
|
||||
if (Array.isArray(data.units)) {
|
||||
for (const unit of data.units) {
|
||||
@@ -49,7 +48,6 @@ export function validateBackupBundle(input: unknown): ValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Check ingredient integrity
|
||||
const ingredientIds = new Set<string>();
|
||||
if (Array.isArray(data.ingredients)) {
|
||||
for (const ingredient of data.ingredients) {
|
||||
@@ -61,7 +59,6 @@ export function validateBackupBundle(input: unknown): ValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Check recipe integrity
|
||||
if (Array.isArray(data.recipes)) {
|
||||
for (const recipe of data.recipes) {
|
||||
if (!recipe.id || !recipe.title || !recipe.yield_unit_id) {
|
||||
|
||||
@@ -19,7 +19,7 @@ const base: Recipe = {
|
||||
title: "Dough",
|
||||
categories: [],
|
||||
tags: [],
|
||||
yield: { amount: { quantity: 200, unit_id: "gram" }, servings: 4 },
|
||||
yield: { amount: { quantity: 200, unit_id: "gram" }, serving_size: { quantity: 50, unit_id: "gram" } },
|
||||
components: [{ id: "main", name: "Main", items: [{ id: "flour", reference: { ingredient_id: "flour" }, amount: { quantity: 100, unit_id: "gram" } }] }],
|
||||
steps: [{ id: "mix", order: 1, instruction: "Mix." }],
|
||||
};
|
||||
|
||||
+12
-8
@@ -1,5 +1,5 @@
|
||||
import type { Ingredient, PrepAction, PurchaseItem, Recipe, RecipeItem, Unit } from "./types.ts";
|
||||
import { convertWithIngredientMeasures } from "./measurement.ts";
|
||||
import { convertWithIngredientMeasures, ingredientWeightGrams } from "./measurement.ts";
|
||||
|
||||
export type CostResult = {
|
||||
batch?: number;
|
||||
@@ -47,10 +47,6 @@ function prepYieldFactor(item: RecipeItem, catalogs: Catalogs): number {
|
||||
return (item.prep ?? []).reduce((factor, prep) => factor * (prep.yield_factor ?? catalogs.prepActions?.get(prep.action_id)?.default_yield_factor ?? 1), 1);
|
||||
}
|
||||
|
||||
function grams(quantity: number, unitId: string, subject: Ingredient, units: Map<string, Unit>): number {
|
||||
return convertWithIngredientMeasures({ quantity, unit_id: unitId }, "gram", subject, units).quantity;
|
||||
}
|
||||
|
||||
function latestPrice(item: PurchaseItem, currency: string) {
|
||||
return item.prices
|
||||
.filter((price) => price.currency === currency)
|
||||
@@ -61,7 +57,7 @@ function usableCostPerGram(ingredient: Ingredient, item: PurchaseItem, currency:
|
||||
const price = latestPrice(item, currency);
|
||||
if (!price) return undefined;
|
||||
try {
|
||||
const packageWeightG = grams(item.package.quantity, item.package.unit_id, ingredient, units) * (item.package.units_per_case ?? 1);
|
||||
const packageWeightG = ingredientWeightGrams(item.package, ingredient, units) * (item.package.units_per_case ?? 1);
|
||||
const usableWeightG = packageWeightG * (item.package.usable_yield_factor ?? 1);
|
||||
return usableWeightG > 0 ? price.amount / usableWeightG : undefined;
|
||||
} catch {
|
||||
@@ -92,7 +88,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
if (!ingredient) throw new Error(`Unknown ingredient: ${item.reference.ingredient_id}`);
|
||||
let usableWeightG: number;
|
||||
try {
|
||||
usableWeightG = grams(item.amount.quantity, item.amount.unit_id, ingredient, catalogs.units);
|
||||
usableWeightG = ingredientWeightGrams(item.amount, ingredient, catalogs.units);
|
||||
} catch (error) {
|
||||
warnings.push(`${ingredient.name}: ${(error as Error).message}`);
|
||||
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", completeness:0 });
|
||||
@@ -155,9 +151,17 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
warnings.push("Recipe yield is not expressed as a mass; per-100 g cost unavailable");
|
||||
}
|
||||
const hasCost = pricedWeightG > 0;
|
||||
let servingWeightG: number | undefined;
|
||||
if (recipe.yield.serving_size) {
|
||||
try {
|
||||
servingWeightG = convertWithIngredientMeasures(recipe.yield.serving_size, "gram", { id: recipe.id, name: recipe.title, schema_version: 2, status: "active", categories: [], measure_conversions: recipe.measure_conversions }, catalogs.units).quantity;
|
||||
} catch {
|
||||
warnings.push("Serving size must connect to weight for per-serving cost");
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(hasCost ? { batch } : {}),
|
||||
...(hasCost && recipe.yield.servings ? { perServing: batch / recipe.yield.servings } : {}),
|
||||
...(hasCost && yieldWeightG && servingWeightG ? { perServing: batch * servingWeightG / yieldWeightG } : {}),
|
||||
...(hasCost && yieldWeightG && yieldWeightG > 0 ? { per100g: batch * 100 / yieldWeightG } : {}),
|
||||
currency,
|
||||
inputWeightG,
|
||||
|
||||
+11
-23
@@ -1,5 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
@@ -12,15 +10,11 @@ import {
|
||||
saveRecipeMetadata,
|
||||
saveRecipeStructure,
|
||||
} from "./database";
|
||||
import { isRecipeId } from "./recipe-id";
|
||||
import { createTestDatabase } from "./test-database";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const schemaPath = path.resolve(process.cwd(), "migrations", "001_initial.sql");
|
||||
const schemaSql = fs.readFileSync(schemaPath, "utf8");
|
||||
db.exec(schemaSql);
|
||||
|
||||
// Seed baseline units
|
||||
function createSeededDatabase(): DatabaseSync {
|
||||
const db = createTestDatabase(["001_initial.sql"]);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"gram", "Gram", "g", "mass", "metric", null, null, null
|
||||
);
|
||||
@@ -31,12 +25,10 @@ function createTestDatabase(): DatabaseSync {
|
||||
"each", "Each", "ea", "count", "customary", null, null, null
|
||||
);
|
||||
|
||||
// Seed baseline ingredient
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("flour", 2, "All-Purpose Flour", "active", "[]", "[]", "{}");
|
||||
|
||||
// Seed baseline recipe
|
||||
db.prepare(
|
||||
"INSERT INTO recipes(id, schema_version, save_version, title, summary, categories_json, tags_json, yield_quantity, yield_unit_id, yield_servings, yield_basis, notes_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
@@ -74,7 +66,7 @@ describe("database optimistic concurrency", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
db = createSeededDatabase();
|
||||
});
|
||||
|
||||
it("updates recipe metadata when save_version matches", () => {
|
||||
@@ -114,7 +106,7 @@ describe("recipe structure saving & transactions", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
db = createSeededDatabase();
|
||||
});
|
||||
|
||||
it("saves components, lines, steps, and new ingredients atomically", () => {
|
||||
@@ -165,7 +157,6 @@ describe("recipe structure saving & transactions", () => {
|
||||
expect(structure.components[0].items).toHaveLength(2);
|
||||
expect(structure.steps).toHaveLength(2);
|
||||
|
||||
// Verify new ingredient was created
|
||||
const water = db.prepare("SELECT * FROM ingredients WHERE id = ?").get("water") as any;
|
||||
expect(water).toBeDefined();
|
||||
expect(water.name).toBe("Filtered Water");
|
||||
@@ -194,12 +185,13 @@ describe("recipe duplication", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
db = createSeededDatabase();
|
||||
});
|
||||
|
||||
it("creates a deep clone with a unique ID and duplicate title", () => {
|
||||
const copyId = duplicateRecipe(db, "bread");
|
||||
expect(copyId).toBe("bread_copy");
|
||||
expect(isRecipeId(copyId)).toBe(true);
|
||||
expect(copyId).not.toBe("bread");
|
||||
|
||||
const copy = editableRecipe(db, copyId);
|
||||
expect(copy).toBeDefined();
|
||||
@@ -216,11 +208,10 @@ describe("recipe quality metrics", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
db = createSeededDatabase();
|
||||
});
|
||||
|
||||
it("identifies placeholder steps and unpriced items", () => {
|
||||
// Add a placeholder step
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "step_todo", 2, "TODO: define baking temperature", 0);
|
||||
@@ -237,11 +228,10 @@ describe("archive lifecycle & safe hard deletion", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
db = createSeededDatabase();
|
||||
});
|
||||
|
||||
it("restores soft-deleted items across multiple entity types", () => {
|
||||
// Soft delete bread recipe and flour ingredient
|
||||
db.prepare("UPDATE recipes SET deleted_at = '2026-08-01' WHERE id = 'bread'").run();
|
||||
db.prepare("UPDATE ingredients SET deleted_at = '2026-08-01', status = 'archived' WHERE id = 'flour'").run();
|
||||
|
||||
@@ -259,14 +249,12 @@ describe("archive lifecycle & safe hard deletion", () => {
|
||||
});
|
||||
|
||||
it("blocks permanent deletion of ingredients used in active recipes", () => {
|
||||
// Flour is used in active bread recipe
|
||||
expect(() =>
|
||||
permanentlyDeleteArchivedItems(db, [{ id: "flour", type: "ingredient" }])
|
||||
).toThrow(/currently used in active recipe/);
|
||||
});
|
||||
|
||||
it("permanently purges items when no active dependencies exist", () => {
|
||||
// Create an unreferenced ingredient
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("salt", 2, "Kosher Salt", "archived", "[]", "[]", "{}", "2026-08-01");
|
||||
|
||||
+16
-4
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { createSiteProjection, writeSiteProjection } from "../../scripts/lib/site-projection.mjs";
|
||||
import { readOnlyMode } from "./runtime.ts";
|
||||
import { isRecipeId, newRecipeId } from "./recipe-id.ts";
|
||||
|
||||
export const databasePath = path.resolve(process.cwd(), "var/recipe-book.sqlite");
|
||||
export const refreshSiteProjection = (database: DatabaseSync) => writeSiteProjection(database);
|
||||
@@ -46,6 +47,7 @@ export function saveRecipeMetadata(database: DatabaseSync, id: string, expectedV
|
||||
export type RecipeStructure = {
|
||||
save_version: number;
|
||||
new_ingredients?: Array<{ id: string; name: string }>;
|
||||
new_recipes?: Array<{ id: string; name: string }>;
|
||||
metadata?: {
|
||||
title: string; yield_quantity: number; yield_unit_id: string; yield_servings: number | null; yield_basis: string | null;
|
||||
station?: string | null; cover_media_url?: string | null; tags?: string[];
|
||||
@@ -78,6 +80,13 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
if(database.prepare("SELECT 1 FROM ingredients WHERE id=?").get(ingredientId))throw new Error(`Ingredient ${ingredientId} already exists. Reload and try again.`);
|
||||
newIngredients.set(ingredientId,name);
|
||||
}
|
||||
const newRecipes = new Map<string,string>();
|
||||
for (const recipe of structure.new_recipes ?? []) {
|
||||
const recipeId=recipe.id.trim(),name=recipe.name.trim();
|
||||
if(!isRecipeId(recipeId)||!name||newRecipes.has(recipeId)||recipeId===id)throw new Error("New recipes need generated immutable IDs and unique names.");
|
||||
if(database.prepare("SELECT 1 FROM recipes WHERE id=?").get(recipeId))throw new Error(`Recipe ${recipeId} already exists. Reload and try again.`);
|
||||
newRecipes.set(recipeId,name);
|
||||
}
|
||||
if (structure.metadata) {
|
||||
if (!structure.metadata.title.trim()) throw new Error("Recipe name is required.");
|
||||
if (!Number.isFinite(structure.metadata.yield_quantity) || structure.metadata.yield_quantity <= 0) throw new Error("Total yield must be greater than zero.");
|
||||
@@ -99,7 +108,7 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
if(item.nutrition_retention_factor!=null&&(!Number.isFinite(item.nutrition_retention_factor)||item.nutrition_retention_factor<0||item.nutrition_retention_factor>1))throw new Error(`${item.id} nutrition retention must be between 0 and 1.`);
|
||||
if (!database.prepare("SELECT 1 FROM units WHERE id = ?").get(item.unit_id)) throw new Error(`${item.id} uses an unknown unit.`);
|
||||
if (item.ingredient_id && !newIngredients.has(item.ingredient_id) && !database.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id)) throw new Error(`${item.id} references an unknown ingredient.`);
|
||||
if (item.subrecipe_id && (!database.prepare("SELECT 1 FROM recipes WHERE id = ?").get(item.subrecipe_id) || item.subrecipe_id === id)) throw new Error(`${item.id} references an invalid sub-recipe.`);
|
||||
if (item.subrecipe_id && ((!newRecipes.has(item.subrecipe_id) && !database.prepare("SELECT 1 FROM recipes WHERE id = ?").get(item.subrecipe_id)) || item.subrecipe_id === id)) throw new Error(`${item.id} references an invalid sub-recipe.`);
|
||||
}
|
||||
}
|
||||
const stepIds = new Set<string>();
|
||||
@@ -112,6 +121,10 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
try {
|
||||
const ingredientInsert=database.prepare("INSERT INTO ingredients(id,schema_version,name,status,categories_json,tags_json,source_json) VALUES (?,2,?,'active','[]','[]',?)");
|
||||
for(const [ingredientId,name] of newIngredients)ingredientInsert.run(ingredientId,name,JSON.stringify({source_type:"ai_import",title:"Recipe ingredient import",reviewed:false}));
|
||||
const recipeInsert=database.prepare("INSERT INTO recipes(id,schema_version,save_version,title,summary,categories_json,tags_json,yield_quantity,yield_unit_id,yield_servings,yield_basis,notes_json,source_json) VALUES (?,2,1,?,NULL,'[]','[]',1,'gram',NULL,'theoretical','[]','{}')");
|
||||
const newRecipeComponentInsert=database.prepare("INSERT INTO recipe_components(recipe_id,id,position,name,notes_json) VALUES (?,'main',1,'Main','[]')");
|
||||
const newRecipeStepInsert=database.prepare("INSERT INTO recipe_steps(recipe_id,id,position,instruction,critical_control_point) VALUES (?,'step_1',1,'Placeholder.',0)");
|
||||
for(const [recipeId,name] of newRecipes){recipeInsert.run(recipeId,name);newRecipeComponentInsert.run(recipeId);newRecipeStepInsert.run(recipeId);}
|
||||
database.prepare("DELETE FROM recipe_steps WHERE recipe_id = ?").run(id);
|
||||
database.prepare("DELETE FROM recipe_components WHERE recipe_id = ?").run(id);
|
||||
const componentInsert = database.prepare("INSERT INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)");
|
||||
@@ -145,9 +158,9 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
|
||||
export function duplicateRecipe(database:DatabaseSync,id:string):string{
|
||||
const source=database.prepare("SELECT * FROM recipes WHERE id=?").get(id) as any;if(!source)throw new Error("Recipe not found.");
|
||||
const base=`${id}_copy`;let copyId=base,suffix=2;while(database.prepare("SELECT 1 FROM recipes WHERE id=?").get(copyId))copyId=`${base}_${suffix++}`;
|
||||
let copyId=newRecipeId();while(database.prepare("SELECT 1 FROM recipes WHERE id=?").get(copyId))copyId=newRecipeId();
|
||||
database.exec("BEGIN IMMEDIATE");try{
|
||||
database.prepare(`INSERT INTO recipes(id,schema_version,save_version,title,summary,categories_json,tags_json,yield_quantity,yield_unit_id,yield_servings,yield_basis,scaling_mode,scaling_basis_id,scaling_basis_quantity,scaling_basis_unit_id,notes_json,source_json,auto_yield,station,cover_media_url) SELECT ?,schema_version,1,title||' Copy',summary,categories_json,tags_json,yield_quantity,yield_unit_id,yield_servings,yield_basis,scaling_mode,scaling_basis_id,scaling_basis_quantity,scaling_basis_unit_id,notes_json,source_json,auto_yield,station,cover_media_url FROM recipes WHERE id=?`).run(copyId,id);
|
||||
database.prepare(`INSERT INTO recipes(id,schema_version,save_version,title,summary,categories_json,tags_json,yield_quantity,yield_unit_id,yield_servings,yield_basis,scaling_mode,scaling_basis_id,scaling_basis_quantity,scaling_basis_unit_id,notes_json,source_json,auto_yield,station,cover_media_url) SELECT ?,schema_version,1,title||' Copy',summary,categories_json,tags_json,yield_quantity,yield_unit_id,NULL,yield_basis,scaling_mode,scaling_basis_id,scaling_basis_quantity,scaling_basis_unit_id,notes_json,source_json,auto_yield,station,cover_media_url FROM recipes WHERE id=?`).run(copyId,id);
|
||||
database.prepare("INSERT INTO recipe_components SELECT ?,id,position,name,notes_json FROM recipe_components WHERE recipe_id=?").run(copyId,id);
|
||||
database.prepare("INSERT INTO recipe_items(recipe_id,component_id,id,position,ingredient_id,subrecipe_id,quantity,unit_id,percentage,basis_member,optional,notes,nutrition_retention_factor) SELECT ?,component_id,id,position,ingredient_id,subrecipe_id,quantity,unit_id,percentage,basis_member,optional,notes,nutrition_retention_factor FROM recipe_items WHERE recipe_id=?").run(copyId,id);
|
||||
database.prepare("INSERT INTO item_prep_actions SELECT ?,item_id,position,action_id,yield_factor,notes FROM item_prep_actions WHERE recipe_id=?").run(copyId,id);
|
||||
@@ -217,7 +230,6 @@ export function permanentlyDeleteArchivedItems(
|
||||
database: DatabaseSync,
|
||||
items: Array<{ id: string; type: string }>
|
||||
) {
|
||||
// Check dependencies first
|
||||
for (const item of items) {
|
||||
if (item.type === "ingredient") {
|
||||
const activeRecipe = database
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openDatabase } from "../lib/database";
|
||||
import { createMcpTools } from "./tools";
|
||||
import { openDatabase } from "./database";
|
||||
import { createDomainTools } from "./domain-tools";
|
||||
|
||||
describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
describe("Domain Tools Integration", { timeout: 20000 }, () => {
|
||||
it("searches recipes with keywords and filters", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
const all = tools.searchRecipes({ limit: 10 });
|
||||
expect(all.length).toBeGreaterThan(0);
|
||||
expect(all[0]).toHaveProperty("id");
|
||||
@@ -18,7 +18,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
|
||||
const filtered = tools.searchRecipes({ query: "biscotti" });
|
||||
expect(filtered.length).toBeGreaterThan(0);
|
||||
expect(filtered.some((r) => r.id === "chocolate_biscotti")).toBe(true);
|
||||
expect(filtered.some((r) => r.title.toLowerCase().includes("biscotti"))).toBe(true);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -30,15 +30,15 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const base = tools.getRecipe({ id: "chocolate_biscotti" });
|
||||
expect(base.id).toBe("chocolate_biscotti");
|
||||
const tools = createDomainTools(() => db);
|
||||
const recipeId = tools.searchRecipes({ query: "biscotti", limit: 1 })[0].id;
|
||||
const base = tools.getRecipe({ id: recipeId });
|
||||
expect(base.id).toBe(recipeId);
|
||||
expect(base.components.length).toBeGreaterThan(0);
|
||||
expect(base.steps.length).toBeGreaterThan(0);
|
||||
expect(base.scale_factor).toBe(1);
|
||||
|
||||
// Scale by 2x
|
||||
const doubled = tools.getRecipe({ id: "chocolate_biscotti", scale_factor: 2 });
|
||||
const doubled = tools.getRecipe({ id: recipeId, scale_factor: 2 });
|
||||
expect(doubled.scale_factor).toBe(2);
|
||||
expect(doubled.yield.quantity).toBe(base.yield.quantity * 2);
|
||||
expect(doubled.components[0].items[0].quantity).toBe(
|
||||
@@ -55,7 +55,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
const audit = tools.auditRecipeQuality();
|
||||
expect(audit.summary.total).toBeGreaterThan(0);
|
||||
expect(audit.recipes.length).toBe(audit.summary.total);
|
||||
@@ -72,7 +72,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
const ings = tools.searchIngredients({ query: "flour", limit: 5 });
|
||||
expect(ings.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
|
||||
const createRes = tools.saveIngredient({
|
||||
name: "Test Matcha Powder",
|
||||
@@ -136,7 +136,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
expect(() => tools.deleteIngredient({ id: "sugar" })).toThrow(/used in active recipe/);
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -149,9 +149,8 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
|
||||
// Create purchase item with initial price
|
||||
const createRes = tools.savePurchaseItem({
|
||||
ingredient_id: "flour_all_purpose",
|
||||
name: "King Arthur AP Flour 25 lb",
|
||||
@@ -167,13 +166,11 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
expect(createRes.success).toBe(true);
|
||||
const piId = createRes.purchase_item_id;
|
||||
|
||||
// Get detail
|
||||
const detail = tools.getPurchaseItem({ id: piId });
|
||||
expect(detail.name).toBe("King Arthur AP Flour 25 lb");
|
||||
expect(detail.price_history.length).toBe(1);
|
||||
expect(detail.price_history[0].amount).toBe(18.50);
|
||||
|
||||
// Record new price observation
|
||||
const priceRes = tools.recordPriceObservation({
|
||||
purchase_item_id: piId,
|
||||
amount: 19.25,
|
||||
@@ -182,7 +179,6 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
});
|
||||
expect(priceRes.success).toBe(true);
|
||||
|
||||
// Clean up
|
||||
db.prepare("DELETE FROM price_observations WHERE purchase_item_id = ?").run(piId);
|
||||
db.prepare("DELETE FROM purchase_items WHERE id = ?").run(piId);
|
||||
} finally {
|
||||
@@ -196,7 +192,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
|
||||
const units = tools.listUnits({ dimension: "mass" });
|
||||
expect(units.length).toBeGreaterThan(0);
|
||||
@@ -221,12 +217,14 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
const biscottiId = tools.searchRecipes({ query: "biscotti", limit: 1 })[0].id;
|
||||
const cinnamonId = tools.searchRecipes({ query: "cinnamon sugar", limit: 1 })[0].id;
|
||||
|
||||
const createRes = tools.saveRecipeBook({
|
||||
name: "Test Holiday Pastries",
|
||||
description: "Holiday baked goods collection",
|
||||
recipe_ids: ["chocolate_biscotti", "cinnamon_sugar"],
|
||||
recipe_ids: [biscottiId, cinnamonId],
|
||||
});
|
||||
expect(createRes.success).toBe(true);
|
||||
const bookId = createRes.book_id;
|
||||
@@ -252,7 +250,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
|
||||
const createRes = tools.createInventoryCountSession({
|
||||
title: "Test Week Count",
|
||||
@@ -287,7 +285,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
|
||||
const kgToG = tools.convertUnits({
|
||||
quantity: 1,
|
||||
@@ -314,12 +312,13 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const cost = tools.calculateRecipeCost({ recipe_id: "chocolate_biscotti", currency: "USD" });
|
||||
const tools = createDomainTools(() => db);
|
||||
const recipeId = tools.searchRecipes({ query: "biscotti", limit: 1 })[0].id;
|
||||
const cost = tools.calculateRecipeCost({ recipe_id: recipeId, currency: "USD" });
|
||||
expect(cost).toHaveProperty("currency", "USD");
|
||||
expect(cost).toHaveProperty("lines");
|
||||
|
||||
const nutrition = tools.calculateRecipeNutrition({ recipe_id: "chocolate_biscotti" });
|
||||
const nutrition = tools.calculateRecipeNutrition({ recipe_id: recipeId });
|
||||
expect(nutrition).toHaveProperty("batch");
|
||||
expect(nutrition).toHaveProperty("inputWeightG");
|
||||
} finally {
|
||||
@@ -333,7 +332,7 @@ describe("MCP Tools & Domain Integration", { timeout: 20000 }, () => {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const tools = createDomainTools(() => db);
|
||||
const stats = tools.getDatabaseStats();
|
||||
expect(stats.recipes).toBeGreaterThan(100);
|
||||
expect(stats.ingredients).toBeGreaterThan(100);
|
||||
@@ -6,33 +6,27 @@ import {
|
||||
restoreArchivedItems as restoreArchivedItemsDb,
|
||||
permanentlyDeleteArchivedItems as permanentlyDeleteArchivedItemsDb,
|
||||
type QualityRecipe,
|
||||
} from "../lib/database.ts";
|
||||
import { loadCatalogs } from "../lib/data.ts";
|
||||
import { exportDatabase } from "../lib/backup/export-database.ts";
|
||||
import { calculateCost, type CostResult } from "../lib/costing.ts";
|
||||
import { calculateNutrition, type NutritionResult } from "../lib/nutrition.ts";
|
||||
import { convert, convertWithIngredientMeasures } from "../lib/measurement.ts";
|
||||
} from "./database.ts";
|
||||
import { loadCatalogs } from "./data.ts";
|
||||
import { exportDatabase } from "./backup/export-database.ts";
|
||||
import { calculateCost, type CostResult } from "./costing.ts";
|
||||
import { calculateNutrition, type NutritionResult } from "./nutrition.ts";
|
||||
import { convert, convertWithIngredientMeasures } from "./measurement.ts";
|
||||
import {
|
||||
getInventoryCounts,
|
||||
getInventoryCountDetail as getInvCountDetailRepo,
|
||||
createInventoryCount as createInvCountRepo,
|
||||
saveInventoryCountItems,
|
||||
type InventoryCountDetail,
|
||||
} from "../lib/repository/inventory-repository.ts";
|
||||
import { titleCase } from "../lib/format.ts";
|
||||
import type { Recipe, Ingredient, Unit, Equipment, PrepAction } from "../lib/types.ts";
|
||||
} from "./repository/inventory-repository.ts";
|
||||
import { titleCase } from "./format.ts";
|
||||
import type { Recipe, Ingredient, Unit, Equipment, PrepAction } from "./types.ts";
|
||||
import { newRecipeId } from "./recipe-id.ts";
|
||||
|
||||
function buildCatalogs() {
|
||||
return loadCatalogs();
|
||||
}
|
||||
|
||||
export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
return {
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Search Recipes
|
||||
// -----------------------------------------------------------------------
|
||||
searchRecipes(args: { query?: string; category?: string; tag?: string; limit?: number }) {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
const q = (args.query ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
let results = [...catalogs.recipes.values()] as Recipe[];
|
||||
@@ -64,7 +58,7 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
yield: {
|
||||
quantity: r.yield.amount.quantity,
|
||||
unit_id: r.yield.amount.unit_id,
|
||||
servings: r.yield.servings ?? null,
|
||||
serving_size: r.yield.serving_size ?? null,
|
||||
},
|
||||
component_count: r.components.length,
|
||||
item_count: r.components.reduce((n, c) => n + c.items.length, 0),
|
||||
@@ -72,11 +66,8 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
}));
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Get Recipe (with scaling)
|
||||
// -----------------------------------------------------------------------
|
||||
getRecipe(args: { id: string; scale_factor?: number; target_yield?: number; target_yield_unit?: string }) {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.id}`);
|
||||
|
||||
@@ -109,7 +100,7 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
quantity: recipe.yield.amount.quantity * scaleFactor,
|
||||
base_quantity: recipe.yield.amount.quantity,
|
||||
unit_id: recipe.yield.amount.unit_id,
|
||||
servings: recipe.yield.servings ? recipe.yield.servings * scaleFactor : null,
|
||||
serving_size: recipe.yield.serving_size ?? null,
|
||||
basis: recipe.yield.basis ?? null,
|
||||
},
|
||||
scale_factor: scaleFactor,
|
||||
@@ -154,16 +145,13 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Save Recipe
|
||||
// -----------------------------------------------------------------------
|
||||
saveRecipe(args: {
|
||||
id?: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
yield_servings?: number | null;
|
||||
serving_size?: { quantity: number; unit_id: string } | null;
|
||||
yield_basis?: string | null;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
@@ -180,6 +168,7 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
basis_member?: boolean;
|
||||
optional?: boolean;
|
||||
notes?: string | null;
|
||||
yield_after_cooking?: number;
|
||||
}>;
|
||||
}>;
|
||||
steps: Array<{
|
||||
@@ -193,24 +182,12 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
|
||||
let recipeId: string;
|
||||
const existing = args.id ? (db.prepare("SELECT save_version FROM recipes WHERE id = ?").get(args.id) as any) : null;
|
||||
if (args.id && !existing) throw new Error("Unknown recipe ID. Omit id when creating a recipe.");
|
||||
const isNew = !existing;
|
||||
|
||||
if (isNew) {
|
||||
if (!args.id) {
|
||||
const base = args.title
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "recipe";
|
||||
recipeId = base;
|
||||
let suffix = 2;
|
||||
while (db.prepare("SELECT 1 FROM recipes WHERE id = ?").get(recipeId)) {
|
||||
recipeId = `${base}_${suffix++}`;
|
||||
}
|
||||
} else {
|
||||
recipeId = args.id;
|
||||
}
|
||||
recipeId = newRecipeId();
|
||||
while (db.prepare("SELECT 1 FROM recipes WHERE id = ?").get(recipeId)) recipeId = newRecipeId();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO recipes (
|
||||
@@ -226,7 +203,7 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
JSON.stringify(args.tags ?? []),
|
||||
args.yield_quantity,
|
||||
args.yield_unit_id,
|
||||
args.yield_servings ?? null,
|
||||
null,
|
||||
args.yield_basis ?? "theoretical",
|
||||
JSON.stringify(args.notes ?? [])
|
||||
);
|
||||
@@ -243,7 +220,7 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
title: args.title,
|
||||
yield_quantity: args.yield_quantity,
|
||||
yield_unit_id: args.yield_unit_id,
|
||||
yield_servings: args.yield_servings ?? null,
|
||||
yield_servings: null,
|
||||
yield_basis: args.yield_basis ?? "theoretical",
|
||||
tags: args.tags ?? [],
|
||||
},
|
||||
@@ -259,6 +236,7 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
...(item.percentage != null ? { percentage: item.percentage } : {}),
|
||||
basis_member: item.basis_member ?? false,
|
||||
optional: item.optional ?? false,
|
||||
nutrition_retention_factor: item.yield_after_cooking == null ? 1 : item.yield_after_cooking / 100,
|
||||
...(item.notes ? { notes: item.notes } : {}),
|
||||
prep: [],
|
||||
})),
|
||||
@@ -272,7 +250,20 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
|
||||
saveRecipeStructure(db, recipeId, structure as any);
|
||||
|
||||
// Update recipe-level attributes if provided
|
||||
if(args.serving_size){
|
||||
const catalogs=loadCatalogs(),savedRecipe=catalogs.recipes.get(recipeId);
|
||||
if(!savedRecipe)throw new Error("Saved recipe could not be reloaded.");
|
||||
const conversionSubject={schema_version:2 as const,id:recipeId,name:args.title,status:"active",categories:[],measure_conversions:savedRecipe.measure_conversions};
|
||||
try{
|
||||
convertWithIngredientMeasures(args.serving_size,"gram",conversionSubject,catalogs.units);
|
||||
convertWithIngredientMeasures(savedRecipe.yield.amount,"gram",conversionSubject,catalogs.units);
|
||||
}catch{throw new Error("Serving size and total yield must connect through a weight equivalency.");}
|
||||
const row=db.prepare("SELECT source_json FROM recipes WHERE id=?").get(recipeId) as {source_json:string};
|
||||
const source=JSON.parse(row.source_json??"{}");source.yield={...(source.yield??{}),serving_size:args.serving_size};
|
||||
db.prepare("UPDATE recipes SET yield_servings=NULL,source_json=? WHERE id=?").run(JSON.stringify(source),recipeId);
|
||||
refreshSiteProjection(db);
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
if (args.summary !== undefined) {
|
||||
@@ -300,9 +291,6 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Delete / Archive Recipe
|
||||
// -----------------------------------------------------------------------
|
||||
deleteRecipe(args: { id: string }) {
|
||||
const db = getDb();
|
||||
const existing = db.prepare("SELECT title FROM recipes WHERE id = ? AND deleted_at IS NULL").get(args.id) as { title: string } | undefined;
|
||||
@@ -329,9 +317,6 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Recipe Quality Audit
|
||||
// -----------------------------------------------------------------------
|
||||
auditRecipeQuality(): { summary: { total: number; clean: number; needs_attention: number }; recipes: QualityRecipe[] } {
|
||||
const db = getDb();
|
||||
const rows = recipeQualityRows(db);
|
||||
@@ -349,11 +334,8 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. Calculate Recipe Cost
|
||||
// -----------------------------------------------------------------------
|
||||
calculateRecipeCost(args: { recipe_id: string; currency?: string }): CostResult {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.recipe_id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
|
||||
|
||||
@@ -366,11 +348,8 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
}, args.currency ?? "USD");
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 7. Calculate Recipe Nutrition
|
||||
// -----------------------------------------------------------------------
|
||||
calculateRecipeNutrition(args: { recipe_id: string; serving_size_g?: number }): NutritionResult {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.recipe_id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
|
||||
|
||||
@@ -382,11 +361,8 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
});
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 8. Search Ingredients
|
||||
// -----------------------------------------------------------------------
|
||||
searchIngredients(args: { query?: string; category?: string; missing_cost?: boolean; limit?: number }) {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
const q = (args.query ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
|
||||
@@ -439,11 +415,8 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
}));
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 9. Get Ingredient Detail
|
||||
// -----------------------------------------------------------------------
|
||||
getIngredient(args: { id: string }) {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
const ing = catalogs.ingredients.get(args.id);
|
||||
if (!ing) throw new Error(`Ingredient not found: ${args.id}`);
|
||||
|
||||
@@ -487,9 +460,6 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 10. Save Ingredient
|
||||
// -----------------------------------------------------------------------
|
||||
saveIngredient(args: {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -574,9 +544,6 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 11. Delete / Archive Ingredient
|
||||
// -----------------------------------------------------------------------
|
||||
deleteIngredient(args: { id: string }) {
|
||||
const db = getDb();
|
||||
const existing = db.prepare("SELECT name FROM ingredients WHERE id = ? AND deleted_at IS NULL").get(args.id) as { name: string } | undefined;
|
||||
@@ -603,9 +570,6 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 12. Purchasing & Price Observations
|
||||
// -----------------------------------------------------------------------
|
||||
listPurchaseItems(args: { ingredient_id?: string; status?: string; limit?: number }) {
|
||||
const db = getDb();
|
||||
let query = `
|
||||
@@ -787,11 +751,8 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 13. Convert Units
|
||||
// -----------------------------------------------------------------------
|
||||
convertUnits(args: { ingredient_id?: string; quantity: number; from_unit: string; to_unit: string }) {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
const units = catalogs.units;
|
||||
|
||||
const fromUnit = units.get(args.from_unit);
|
||||
@@ -830,11 +791,8 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 14. Reference Catalogs: Units, Equipment, Prep Actions
|
||||
// -----------------------------------------------------------------------
|
||||
listUnits(args?: { dimension?: string; system?: string }): Unit[] {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
let units = [...catalogs.units.values()];
|
||||
if (args?.dimension) units = units.filter((u) => u.dimension === args.dimension);
|
||||
if (args?.system) units = units.filter((u) => u.system === args.system);
|
||||
@@ -842,20 +800,17 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
listEquipment(args?: { category?: string }): Equipment[] {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
let items = [...catalogs.equipment.values()];
|
||||
if (args?.category) items = items.filter((e) => e.category === args.category);
|
||||
return items;
|
||||
},
|
||||
|
||||
listPrepActions(): PrepAction[] {
|
||||
const catalogs = buildCatalogs();
|
||||
const catalogs = loadCatalogs();
|
||||
return [...catalogs.prepActions.values()];
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 15. Inventory Counts
|
||||
// -----------------------------------------------------------------------
|
||||
listInventoryCounts(args: { status?: "all" | "open" | "completed" }) {
|
||||
const db = getDb();
|
||||
let counts = getInventoryCounts(db);
|
||||
@@ -919,9 +874,6 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 16. Recipe Books / Collections
|
||||
// -----------------------------------------------------------------------
|
||||
listRecipeBooks() {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
@@ -1022,9 +974,6 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 17. Archive & Restore Management
|
||||
// -----------------------------------------------------------------------
|
||||
listArchivedItems() {
|
||||
const db = getDb();
|
||||
const recipes = db.prepare("SELECT id, title as name, deleted_at, 'recipe' as type FROM recipes WHERE deleted_at IS NOT NULL").all();
|
||||
@@ -1063,9 +1012,6 @@ export function createMcpTools(getDb: () => DatabaseSync) {
|
||||
};
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 18. Database Export & Stats
|
||||
// -----------------------------------------------------------------------
|
||||
exportDatabaseBackup() {
|
||||
const db = getDb();
|
||||
return exportDatabase(db);
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* Formulation - Centralized Icon Library
|
||||
* SVG icons and path definitions for workspace design system
|
||||
*/
|
||||
|
||||
export type TabIconKey = "method" | "costing" | "equivalencies" | "nutrition" | "formula" | "overview";
|
||||
export type EntityIconKey = "recipe" | "ingredient" | "book" | "purchase" | "inventory";
|
||||
export type UiIconKey = "chevronRight" | "chevronLeft" | "edit" | "more" | "plus" | "search" | "info" | "warning";
|
||||
|
||||
/**
|
||||
* Tab Icons (viewBox & SVG path) used in workspace & entity read subheaders
|
||||
*/
|
||||
export const TAB_ICONS: Record<TabIconKey, string> = {
|
||||
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",
|
||||
@@ -28,9 +21,6 @@ export const TAB_VIEWBOXES: Record<TabIconKey, string> = {
|
||||
overview: "0 0 24 24"
|
||||
};
|
||||
|
||||
/**
|
||||
* Entity Type Circular Badge Icons
|
||||
*/
|
||||
export const TYPE_ICONS: Record<EntityIconKey, 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",
|
||||
@@ -47,9 +37,6 @@ export const TYPE_ICON_TRANSFORMS: Record<EntityIconKey, string | undefined> = {
|
||||
inventory: undefined
|
||||
};
|
||||
|
||||
/**
|
||||
* General UI & Action Icons
|
||||
*/
|
||||
export const UI_ICONS: Record<UiIconKey, string> = {
|
||||
chevronRight: "M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z",
|
||||
chevronLeft: "M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z",
|
||||
@@ -61,9 +48,6 @@ export const UI_ICONS: Record<UiIconKey, string> = {
|
||||
warning: "M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate tab icon markup for Astro / HTML templates
|
||||
*/
|
||||
export function getTabIconHtml(key: TabIconKey): string {
|
||||
const viewBox = TAB_VIEWBOXES[key] || "0 0 24 24";
|
||||
const path = TAB_ICONS[key];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeIngredientText, validateIngredientParse } from "./ingredient-parser";
|
||||
import {
|
||||
normalizeIngredientText,
|
||||
parseIngredientsDeterministic,
|
||||
validateIngredientParse,
|
||||
} from "./ingredient-parser";
|
||||
|
||||
describe("ingredient parser", () => {
|
||||
it("normalizes copied checklist text and Unicode fractions", () => {
|
||||
@@ -24,4 +28,53 @@ describe("ingredient parser", () => {
|
||||
}] }], warnings:[],
|
||||
}, "salt")).toThrow("invalid quantity");
|
||||
});
|
||||
|
||||
it("deterministically parses ingredient lines with headers, quantities, units, and notes", () => {
|
||||
const raw = `
|
||||
Dry Mix:
|
||||
500g flour
|
||||
1/2 cup semolina
|
||||
salt to taste
|
||||
|
||||
Wet:
|
||||
5 cloves garlic
|
||||
3 egg yolks
|
||||
olive oil (room temp)
|
||||
`;
|
||||
|
||||
const result = parseIngredientsDeterministic(raw);
|
||||
expect(result.components).toHaveLength(2);
|
||||
expect(result.components[0].name).toBe("Dry Mix");
|
||||
expect(result.components[0].items).toHaveLength(3);
|
||||
expect(result.components[0].items[0]).toMatchObject({
|
||||
quantity: 500,
|
||||
unit: "gram",
|
||||
ingredient: "flour",
|
||||
});
|
||||
expect(result.components[0].items[1]).toMatchObject({
|
||||
quantity: 0.5,
|
||||
unit: "cup",
|
||||
ingredient: "semolina",
|
||||
});
|
||||
expect(result.components[0].items[2]).toMatchObject({
|
||||
ingredient: "salt",
|
||||
note: "to taste",
|
||||
});
|
||||
|
||||
expect(result.components[1].name).toBe("Wet");
|
||||
expect(result.components[1].items).toHaveLength(3);
|
||||
expect(result.components[1].items[0]).toMatchObject({
|
||||
quantity: 5,
|
||||
unit: "clove",
|
||||
ingredient: "garlic",
|
||||
});
|
||||
expect(result.components[1].items[1]).toMatchObject({
|
||||
quantity: 3,
|
||||
ingredient: "egg yolks",
|
||||
});
|
||||
expect(result.components[1].items[2]).toMatchObject({
|
||||
ingredient: "olive oil",
|
||||
note: "room temp",
|
||||
});
|
||||
});
|
||||
});
|
||||
+205
-121
@@ -29,7 +29,7 @@ const FRACTIONS: Record<string, string> = {
|
||||
|
||||
export function normalizeIngredientText(value: string): string {
|
||||
return value
|
||||
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (value) => FRACTIONS[value] ?? value)
|
||||
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (match) => FRACTIONS[match] ?? match)
|
||||
.normalize("NFKC")
|
||||
.replace(/⁄/g, "/")
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
|
||||
@@ -44,143 +44,227 @@ export function normalizeIngredientText(value: string): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const responseSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["components", "warnings"],
|
||||
properties: {
|
||||
components: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name", "items"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["source_line", "quantity", "unit", "ingredient", "preparation", "note", "optional", "alternatives"],
|
||||
properties: {
|
||||
source_line: { type: "string" },
|
||||
quantity: { type: ["number", "null"] },
|
||||
unit: { type: ["string", "null"] },
|
||||
ingredient: { type: "string" },
|
||||
preparation: { type: ["string", "null"] },
|
||||
note: { type: ["string", "null"] },
|
||||
optional: { type: "boolean" },
|
||||
alternatives: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
warnings: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
} as const;
|
||||
function parseFractionValue(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
const [low, high] = trimmed.split("-").map((v) => Number(v.trim()));
|
||||
return (low + high) / 2;
|
||||
}
|
||||
const mixed = trimmed.match(/^(\d+)\s+([0-9]+)\/([0-9]+)$/);
|
||||
if (mixed) {
|
||||
const whole = Number(mixed[1]);
|
||||
const num = Number(mixed[2]);
|
||||
const den = Number(mixed[3]);
|
||||
if (den === 0) return null;
|
||||
return whole + num / den;
|
||||
}
|
||||
const frac = trimmed.match(/^([0-9]+)\/([0-9]+)$/);
|
||||
if (frac) {
|
||||
const num = Number(frac[1]);
|
||||
const den = Number(frac[2]);
|
||||
if (den === 0) return null;
|
||||
return num / den;
|
||||
}
|
||||
const num = Number(trimmed);
|
||||
return Number.isFinite(num) && num > 0 ? num : null;
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a purpose-built culinary ingredient parser. Convert normalized recipe ingredient text into JSON only.
|
||||
const UNIT_MAP: Record<string, string> = {
|
||||
g: "gram", gram: "gram", grams: "gram",
|
||||
kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
|
||||
oz: "ounce", ounce: "ounce", ounces: "ounce",
|
||||
lb: "pound", lbs: "pound", pound: "pound", pounds: "pound",
|
||||
tsp: "teaspoon", tsps: "teaspoon", teaspoon: "teaspoon", teaspoons: "teaspoon",
|
||||
tbsp: "tablespoon", tbsps: "tablespoon", tbs: "tablespoon", tablespoon: "tablespoon", tablespoons: "tablespoon",
|
||||
c: "cup", cup: "cup", cups: "cup",
|
||||
ml: "milliliter", milliliter: "milliliter", milliliters: "milliliter",
|
||||
l: "liter", liter: "liter", liters: "liter",
|
||||
clove: "clove", cloves: "clove",
|
||||
ea: "each", each: "each",
|
||||
pinch: "pinch", pinches: "pinch",
|
||||
dash: "dash", dashes: "dash",
|
||||
slice: "slice", slices: "slice",
|
||||
sprig: "sprig", sprigs: "sprig",
|
||||
stalk: "stalk", stalks: "stalk",
|
||||
head: "head", heads: "head",
|
||||
bunch: "bunch", bunches: "bunch",
|
||||
can: "can", cans: "can",
|
||||
pkg: "package", package: "package", packages: "package",
|
||||
bottle: "bottle", bottles: "bottle",
|
||||
};
|
||||
|
||||
Rules:
|
||||
- Preserve the meaning and never invent an ingredient, amount, unit, or preparation.
|
||||
- Convert fractions and mixed numbers to decimal quantities.
|
||||
- Use singular conventional unit names such as gram, ounce, pound, teaspoon, tablespoon, cup, milliliter, liter, or each.
|
||||
- A line ending in a colon is a component header. Use "Main" when there is no header.
|
||||
- Group consecutive lines under one component. Do not create a new Main component for each line.
|
||||
- ingredient contains only the ingredient identity, never its quantity, unit, size, preparation, or note. For example, "15 ounces tomato sauce" becomes quantity 15, unit "ounce", ingredient "tomato sauce".
|
||||
- Treat sizes such as small, medium, and large as preparation or notes and use unit "each". For example, "1 medium onion, chopped" becomes quantity 1, unit "each", ingredient "onion", preparation "medium; chopped".
|
||||
- Split a source line containing two independently required ingredients into two items, retaining the same source_line.
|
||||
- Specifically, "salt and freshly ground black pepper, to taste" becomes separate salt and black pepper items.
|
||||
- Keep alternatives in alternatives instead of adding them as required items.
|
||||
- Put physical treatment such as chopped, minced, sliced, freshly ground, dried, or drained in preparation.
|
||||
- Put serving instructions, "to taste", temperatures, and other qualifications in note.
|
||||
- Set optional true when the source explicitly says optional.
|
||||
- Use null quantity and unit when the source supplies none. Do not guess.
|
||||
- Use null rather than an empty string. Do not repeat alternatives in note.
|
||||
- Retain every source line. Add a warning for ambiguity.`;
|
||||
export function parseSingleIngredientLine(sourceLine: string): ParsedIngredient {
|
||||
const line = sourceLine.trim();
|
||||
const optional = /\boptional\b/i.test(line);
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
let workingLine = line;
|
||||
let note: string | null = null;
|
||||
const parenMatch = workingLine.match(/\(([^)]+)\)/);
|
||||
if (parenMatch) {
|
||||
const inside = parenMatch[1].trim();
|
||||
if (!/^\s*optional\s*$/i.test(inside)) {
|
||||
note = inside;
|
||||
}
|
||||
workingLine = workingLine.replace(parenMatch[0], " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
let preparation: string | null = null;
|
||||
const commaIdx = workingLine.indexOf(",");
|
||||
if (commaIdx > 0) {
|
||||
const afterComma = workingLine.slice(commaIdx + 1).trim();
|
||||
workingLine = workingLine.slice(0, commaIdx).trim();
|
||||
if (afterComma) {
|
||||
if (/to taste/i.test(afterComma)) {
|
||||
note = note ? `${note}; ${afterComma}` : afterComma;
|
||||
} else {
|
||||
preparation = afterComma;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quantity: number | null = null;
|
||||
let unit: string | null = null;
|
||||
let ingredientName = workingLine;
|
||||
|
||||
const qtyUnitPattern = /^(\d+(?:\.\d+)?(?:\s+\d+\/\d+|\/\d+)?|\d+\/\d+|\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?)\s*([a-zA-Z]+)?\s+(.*)$/;
|
||||
const match1 = workingLine.match(qtyUnitPattern);
|
||||
|
||||
if (match1) {
|
||||
const qtyVal = parseFractionValue(match1[1]);
|
||||
const possibleUnit = (match1[2] || "").toLowerCase();
|
||||
if (qtyVal != null) {
|
||||
quantity = qtyVal;
|
||||
if (possibleUnit && UNIT_MAP[possibleUnit]) {
|
||||
unit = UNIT_MAP[possibleUnit];
|
||||
ingredientName = match1[3].trim();
|
||||
} else if (possibleUnit) {
|
||||
ingredientName = `${possibleUnit} ${match1[3]}`.trim();
|
||||
} else {
|
||||
ingredientName = match1[3].trim();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const attachedMatch = workingLine.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(.*)$/);
|
||||
if (attachedMatch) {
|
||||
const qtyVal = parseFractionValue(attachedMatch[1]);
|
||||
const possibleUnit = attachedMatch[2].toLowerCase();
|
||||
if (qtyVal != null && UNIT_MAP[possibleUnit]) {
|
||||
quantity = qtyVal;
|
||||
unit = UNIT_MAP[possibleUnit];
|
||||
ingredientName = attachedMatch[3].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (/to taste/i.test(ingredientName)) {
|
||||
ingredientName = ingredientName.replace(/\bto taste\b/i, "").trim();
|
||||
if (!note) note = "to taste";
|
||||
}
|
||||
|
||||
ingredientName = ingredientName.replace(/^of\s+/i, "").trim();
|
||||
if (!ingredientName) ingredientName = line;
|
||||
|
||||
return {
|
||||
source_line: line,
|
||||
quantity,
|
||||
unit,
|
||||
ingredient: ingredientName,
|
||||
preparation,
|
||||
note,
|
||||
optional,
|
||||
alternatives: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIngredientsDeterministic(text: string): IngredientParseResult {
|
||||
const normalizedText = normalizeIngredientText(text);
|
||||
if (!normalizedText) {
|
||||
throw new Error("Enter at least one ingredient.");
|
||||
}
|
||||
|
||||
const lines = normalizedText.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
const components: ParsedIngredientComponent[] = [];
|
||||
let currentComponent: ParsedIngredientComponent = {
|
||||
name: "Main",
|
||||
items: [],
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const isHeader = line.endsWith(":") || /^#+\s+/.test(line);
|
||||
if (isHeader) {
|
||||
const headerName = line.replace(/^#+\s*/, "").replace(/:$/, "").trim();
|
||||
if (currentComponent.items.length > 0 || currentComponent.name !== "Main") {
|
||||
if (currentComponent.items.length > 0) {
|
||||
components.push(currentComponent);
|
||||
}
|
||||
currentComponent = {
|
||||
name: headerName || "Main",
|
||||
items: [],
|
||||
};
|
||||
} else {
|
||||
currentComponent.name = headerName || "Main";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = parseSingleIngredientLine(line);
|
||||
currentComponent.items.push(item);
|
||||
}
|
||||
|
||||
if (currentComponent.items.length > 0) {
|
||||
components.push(currentComponent);
|
||||
}
|
||||
|
||||
if (components.length === 0) {
|
||||
throw new Error("The parser did not find any ingredients.");
|
||||
}
|
||||
|
||||
return {
|
||||
normalized_text: normalizedText,
|
||||
components,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
|
||||
return parseIngredientsDeterministic(text);
|
||||
}
|
||||
|
||||
export function validateIngredientParse(value: unknown, normalizedText: string): IngredientParseResult {
|
||||
if (!value || typeof value !== "object") throw new Error("The parser returned an invalid document.");
|
||||
const source = value as Record<string, unknown>;
|
||||
if (!Array.isArray(source.components) || !Array.isArray(source.warnings)) throw new Error("The parser response is missing components or warnings.");
|
||||
if (!Array.isArray(source.components) || !Array.isArray(source.warnings)) {
|
||||
throw new Error("The parser response is missing components or warnings.");
|
||||
}
|
||||
const components = source.components.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid component.");
|
||||
const component = entry as Record<string, unknown>;
|
||||
if (typeof component.name !== "string" || !component.name.trim() || !Array.isArray(component.items)) throw new Error("The parser returned an invalid component.");
|
||||
const items = component.items.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid ingredient.");
|
||||
const item = entry as Record<string, unknown>;
|
||||
if (typeof component.name !== "string" || !component.name.trim() || !Array.isArray(component.items)) {
|
||||
throw new Error("The parser returned an invalid component.");
|
||||
}
|
||||
const items = component.items.map((rawItem) => {
|
||||
if (!rawItem || typeof rawItem !== "object") throw new Error("The parser returned an invalid ingredient.");
|
||||
const item = rawItem as Record<string, unknown>;
|
||||
if (typeof item.source_line !== "string") throw new Error("The parser returned an ingredient without its source line.");
|
||||
if (typeof item.ingredient !== "string" || !item.ingredient.trim()) throw new Error(`The parser returned an unnamed ingredient for: ${item.source_line}`);
|
||||
if (!(item.quantity === null || typeof item.quantity === "number" && Number.isFinite(item.quantity) && item.quantity > 0)) throw new Error(`The parser returned an invalid quantity for: ${item.source_line}`);
|
||||
if (!isNullableString(item.unit) || !isNullableString(item.preparation) || !isNullableString(item.note)) throw new Error(`The parser returned invalid text fields for: ${item.source_line}`);
|
||||
if (typeof item.optional !== "boolean" || !Array.isArray(item.alternatives) || !item.alternatives.every((value) => typeof value === "string")) throw new Error(`The parser returned invalid qualifications for: ${item.source_line}`);
|
||||
const sourceLine = item.source_line.trim();
|
||||
const sourceSaysOptional = /\boptional\b/i.test(sourceLine);
|
||||
const alternatives = (item.alternatives as string[]).map((value) => value.trim()).filter((value) => value && !/^(?:none|optional)$/i.test(value));
|
||||
const note = item.note?.trim() || null;
|
||||
if (!(item.quantity === null || (typeof item.quantity === "number" && Number.isFinite(item.quantity) && item.quantity > 0))) {
|
||||
throw new Error(`The parser returned an invalid quantity for: ${item.source_line}`);
|
||||
}
|
||||
return {
|
||||
source_line: sourceLine,
|
||||
source_line: String(item.source_line),
|
||||
quantity: item.quantity as number | null,
|
||||
unit: item.unit?.trim().toLowerCase() || null,
|
||||
ingredient: item.ingredient.trim(),
|
||||
preparation: item.preparation?.trim() || null,
|
||||
note: note && (!/^optional$/i.test(note) || sourceSaysOptional) ? note : null,
|
||||
optional: sourceSaysOptional,
|
||||
alternatives,
|
||||
unit: item.unit ? String(item.unit) : null,
|
||||
ingredient: String(item.ingredient),
|
||||
preparation: item.preparation ? String(item.preparation) : null,
|
||||
note: item.note ? String(item.note) : null,
|
||||
optional: Boolean(item.optional),
|
||||
alternatives: Array.isArray(item.alternatives) ? (item.alternatives as string[]) : [],
|
||||
} satisfies ParsedIngredient;
|
||||
});
|
||||
return { name: component.name.trim(), items };
|
||||
}).filter((component) => component.items.length > 0);
|
||||
if (!components.length) throw new Error("The parser did not find any ingredients.");
|
||||
if (!source.warnings.every((value) => typeof value === "string")) throw new Error("The parser returned invalid warnings.");
|
||||
const consolidated: ParsedIngredientComponent[] = [];
|
||||
for (const component of components) {
|
||||
const previous = consolidated.at(-1);
|
||||
if (previous?.name.toLowerCase() === component.name.toLowerCase()) previous.items.push(...component.items);
|
||||
else consolidated.push(component);
|
||||
}
|
||||
return { normalized_text: normalizedText, components: consolidated, warnings: source.warnings as string[] };
|
||||
}
|
||||
}).filter((comp) => comp.items.length > 0);
|
||||
|
||||
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
|
||||
const normalizedText = normalizeIngredientText(text);
|
||||
if (!normalizedText) throw new Error("Enter at least one ingredient.");
|
||||
const endpoint = process.env.FORMULATION_OLLAMA_URL ?? "http://10.0.10.211:11434/api/chat";
|
||||
const model = process.env.FORMULATION_INGREDIENT_PARSER_MODEL ?? "qwen3:4b-instruct";
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: false,
|
||||
think: false,
|
||||
format: responseSchema,
|
||||
options: { temperature: 0, num_predict: 6000 },
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: normalizedText },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Ingredient parser service failed (${response.status}).`);
|
||||
const payload = await response.json() as { message?: { content?: string } };
|
||||
const content = payload.message?.content?.trim();
|
||||
if (!content) throw new Error("Ingredient parser returned an empty response.");
|
||||
let parsed: unknown;
|
||||
const unwrapped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||||
const firstBrace = unwrapped.indexOf("{");
|
||||
const lastBrace = unwrapped.lastIndexOf("}");
|
||||
const json = firstBrace >= 0 && lastBrace > firstBrace ? unwrapped.slice(firstBrace, lastBrace + 1) : unwrapped;
|
||||
try { parsed = JSON.parse(json); }
|
||||
catch { throw new Error("Ingredient parser returned malformed JSON."); }
|
||||
return validateIngredientParse(parsed, normalizedText);
|
||||
if (!components.length) throw new Error("The parser did not find any ingredients.");
|
||||
return { normalized_text: normalizedText, components, warnings: source.warnings as string[] };
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
@@ -9,16 +7,10 @@ import {
|
||||
getInventoryLocations,
|
||||
saveInventoryCountItems,
|
||||
} from "./repository/inventory-repository";
|
||||
import { createTestDatabase } from "./test-database";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const schemaPath = path.resolve(process.cwd(), "migrations", "001_initial.sql");
|
||||
db.exec(fs.readFileSync(schemaPath, "utf8"));
|
||||
const invSchemaPath = path.resolve(process.cwd(), "migrations", "003_inventory.sql");
|
||||
db.exec(fs.readFileSync(invSchemaPath, "utf8"));
|
||||
|
||||
// Seed baseline units
|
||||
function createSeededDatabase(): DatabaseSync {
|
||||
const db = createTestDatabase(["001_initial.sql"]);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"gram", "Gram", "g", "mass", "metric", null, null, null
|
||||
);
|
||||
@@ -26,7 +18,6 @@ function createTestDatabase(): DatabaseSync {
|
||||
"each", "Each", "ea", "count", "customary", null, null, null
|
||||
);
|
||||
|
||||
// Seed ingredients
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("butter", 2, "Unsalted Butter", "active", "[]", "[]", "{}");
|
||||
@@ -34,7 +25,6 @@ function createTestDatabase(): DatabaseSync {
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("flour", 2, "Bread Flour", "active", "[]", "[]", "{}");
|
||||
|
||||
// Seed purchase item for butter: $5.00 for 500g ($0.01/g)
|
||||
db.prepare(
|
||||
"INSERT INTO purchase_items(id, ingredient_id, name, status, package_quantity, package_unit_id, units_per_case, usable_yield_factor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("pi_butter", "butter", "Butter 500g", "active", 500, "gram", 1, 1);
|
||||
@@ -50,7 +40,7 @@ describe("inventory repository", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
db = createSeededDatabase();
|
||||
});
|
||||
|
||||
it("fetches seeded inventory locations", () => {
|
||||
@@ -88,7 +78,6 @@ describe("inventory repository", () => {
|
||||
const walkIn = locations.find((l) => l.name === "Walk-in Cooler")!;
|
||||
const dryStorage = locations.find((l) => l.name === "Dry Storage")!;
|
||||
|
||||
// Count 1000g of butter ($0.01/g = $10.00) in walk-in, and 2000g of flour (unpriced = $0.00) in dry storage
|
||||
saveInventoryCountItems(
|
||||
db,
|
||||
countId,
|
||||
|
||||
@@ -100,3 +100,7 @@ export function convertWithIngredientMeasures(amount:Amount,toUnitId:string,ingr
|
||||
}
|
||||
return convertWithDensity(amount,toUnitId,ingredient,catalog);
|
||||
}
|
||||
|
||||
export function ingredientWeightGrams(amount: Amount, ingredient: Ingredient, catalog: UnitCatalog): number {
|
||||
return convertWithIngredientMeasures(amount, "gram", ingredient, catalog).quantity;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { Ingredient, Recipe, SourceMapping, Unit } from "./types";
|
||||
const units = new Map<string, Unit>([["gram", { schema_version: 2, id: "gram", name: "gram", symbol: "g", dimension: "mass", system: "metric" }]]);
|
||||
const sugar: Ingredient = { schema_version: 2, id: "sugar", name: "sugar", status: "active", categories: [], nutrition_mapping_ids: ["sugar_usda"] };
|
||||
const mapping: SourceMapping = { schema_version: 2, id: "sugar_usda", subject: { type: "ingredient", id: "sugar" }, mapping_type: "nutrition", status: "reviewed", source: { source_type: "usda_fdc", title: "Sugar" }, nutrition_per_100g: { energy_kcal: 400, carbohydrate_g: 100 } };
|
||||
const recipe: Recipe = { schema_version: 2, id: "syrup", title: "Syrup", categories: [], tags: [], yield: { amount: { quantity: 200, unit_id: "gram" }, servings: 4 }, components: [{ id: "main", name: "Main", items: [{ id: "sugar", reference: { ingredient_id: "sugar" }, amount: { quantity: 100, unit_id: "gram" } }] }], steps: [{ id: "mix", order: 1, instruction: "Mix." }] };
|
||||
const recipe: Recipe = { schema_version: 2, id: "syrup", title: "Syrup", categories: [], tags: [], yield: { amount: { quantity: 200, unit_id: "gram" } }, components: [{ id: "main", name: "Main", items: [{ id: "sugar", reference: { ingredient_id: "sugar" }, amount: { quantity: 100, unit_id: "gram" } }] }], steps: [{ id: "mix", order: 1, instruction: "Mix." }] };
|
||||
|
||||
describe("recipe nutrition", () => {
|
||||
const catalogs = (recipeEntries: Recipe[]) => ({
|
||||
@@ -15,13 +15,26 @@ describe("recipe nutrition", () => {
|
||||
mappings: new Map([[mapping.id, mapping]]),
|
||||
});
|
||||
|
||||
it("calculates batch, per-100g, serving values, and completeness", () => {
|
||||
it("does not calculate per-serving values without an explicit serving size", () => {
|
||||
const result = calculateNutrition(recipe, { recipes: new Map([[recipe.id, recipe]]), ingredients: new Map([[sugar.id, sugar]]), units, mappings: new Map([[mapping.id, mapping]]) });
|
||||
expect(result.batch.energy_kcal).toBe(400);
|
||||
expect(result.per100g?.energy_kcal).toBe(200);
|
||||
expect(result.perServing?.energy_kcal).toBe(100);
|
||||
expect(result.perServing).toBeUndefined();
|
||||
expect(result.servingWeightG).toBeUndefined();
|
||||
expect(result.servingsPerBatch).toBeUndefined();
|
||||
expect(result.yieldWeightG).toBe(200);
|
||||
expect(result.completeness).toBe(1);
|
||||
expect(result.labelReady).toBe(false);
|
||||
expect(result.readinessIssues).toContain("Enter an explicit serving size and unit.");
|
||||
});
|
||||
|
||||
it("uses an explicit serving amount and derives servings per batch", () => {
|
||||
const explicit:Recipe={...recipe,yield:{...recipe.yield,serving_size:{quantity:25,unit_id:"gram"}}};
|
||||
const result=calculateNutrition(explicit,catalogs([explicit]));
|
||||
expect(result.servingWeightG).toBe(25);
|
||||
expect(result.servingsPerBatch).toBe(8);
|
||||
expect(result.perServing?.energy_kcal).toBe(50);
|
||||
expect(result.labelReady).toBe(true);
|
||||
});
|
||||
|
||||
it("treats ingredient amounts as usable quantities for nutrition", () => {
|
||||
|
||||
+37
-9
@@ -1,17 +1,22 @@
|
||||
import type { Ingredient, Recipe, SourceMapping, Unit } from "./types.ts";
|
||||
import { convertWithIngredientMeasures } from "./measurement.ts";
|
||||
import { convertWithIngredientMeasures, ingredientWeightGrams } from "./measurement.ts";
|
||||
|
||||
export type NutritionFacts = Record<string, number>;
|
||||
export type NutritionResult = {
|
||||
batch: NutritionFacts;
|
||||
per100g?: NutritionFacts;
|
||||
perServing?: NutritionFacts;
|
||||
servingSize?: { quantity: number; unit_id: string };
|
||||
servingWeightG?: number;
|
||||
servingsPerBatch?: number;
|
||||
inputWeightG: number;
|
||||
calculatedYieldWeightG: number;
|
||||
yieldWeightG?: number;
|
||||
mappedWeightG: number;
|
||||
completeness: number;
|
||||
warnings: string[];
|
||||
readinessIssues: string[];
|
||||
labelReady: boolean;
|
||||
};
|
||||
|
||||
type Catalogs = {
|
||||
@@ -29,10 +34,6 @@ function add(target: NutritionFacts, source: NutritionFacts): void {
|
||||
for (const [key, value] of Object.entries(source)) target[key] = (target[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
function grams(quantity: number, unitId: string, ingredient: Ingredient, catalogs: Catalogs): number {
|
||||
return convertWithIngredientMeasures({ quantity, unit_id: unitId }, "gram", ingredient, catalogs.units).quantity;
|
||||
}
|
||||
|
||||
function reviewedMapping(ingredient: Ingredient, catalogs: Catalogs): SourceMapping | undefined {
|
||||
return (ingredient.nutrition_mapping_ids ?? [])
|
||||
.map((id) => catalogs.mappings.get(id))
|
||||
@@ -46,6 +47,7 @@ export function calculateNutrition(recipe: Recipe, catalogs: Catalogs, stack: st
|
||||
let inputWeightG = 0;
|
||||
let mappedWeightG = 0;
|
||||
let calculatedYieldWeightG=0;
|
||||
const readinessIssues: string[] = [];
|
||||
|
||||
for (const item of recipe.components.flatMap((component) => component.items)) {
|
||||
if (item.optional) continue;
|
||||
@@ -54,9 +56,10 @@ export function calculateNutrition(recipe: Recipe, catalogs: Catalogs, stack: st
|
||||
if (!ingredient) throw new Error(`Unknown ingredient: ${item.reference.ingredient_id}`);
|
||||
let weight: number;
|
||||
try {
|
||||
weight = grams(item.amount.quantity, item.amount.unit_id, ingredient, catalogs);
|
||||
weight = ingredientWeightGrams(item.amount, ingredient, catalogs.units);
|
||||
} catch (error) {
|
||||
warnings.push(`${ingredient.name}: ${(error as Error).message}`);
|
||||
readinessIssues.push(`${ingredient.name} needs a weight equivalency.`);
|
||||
continue;
|
||||
}
|
||||
inputWeightG += weight;
|
||||
@@ -65,6 +68,7 @@ export function calculateNutrition(recipe: Recipe, catalogs: Catalogs, stack: st
|
||||
const mapping = reviewedMapping(ingredient, catalogs);
|
||||
if (!mapping) {
|
||||
warnings.push(`${ingredient.name}: no reviewed nutrition mapping`);
|
||||
readinessIssues.push(`${ingredient.name} needs reviewed USDA or custom nutrition data, or must be explicitly excluded from the finished food.`);
|
||||
continue;
|
||||
}
|
||||
const facts = Object.fromEntries(Object.entries(mapping.nutrition_per_100g!).filter((entry): entry is [string, number] => typeof entry[1] === "number"));
|
||||
@@ -75,12 +79,14 @@ export function calculateNutrition(recipe: Recipe, catalogs: Catalogs, stack: st
|
||||
if (!child) throw new Error(`Unknown sub-recipe: ${item.reference.recipe_id}`);
|
||||
if (item.reference.component_id) {
|
||||
warnings.push(`${child.title}: component-specific nutrition is not available for ${item.reference.component_id}`);
|
||||
readinessIssues.push(`${child.title} does not support component-specific nutrition.`);
|
||||
continue;
|
||||
}
|
||||
const childResult = calculateNutrition(child, catalogs, [...stack, recipe.id]);
|
||||
const childYieldG = childResult.yieldWeightG;
|
||||
if (!childYieldG || childYieldG <= 0) {
|
||||
warnings.push(`${child.title}: sub-recipe yield must be expressed as a positive mass`);
|
||||
readinessIssues.push(`${child.title} needs a weight-convertible total yield.`);
|
||||
continue;
|
||||
}
|
||||
let usedWeightG: number;
|
||||
@@ -88,12 +94,14 @@ export function calculateNutrition(recipe: Recipe, catalogs: Catalogs, stack: st
|
||||
usedWeightG = convertWithIngredientMeasures(item.amount, "gram", { id: child.id, name: child.title, schema_version: 2, status: "active", categories: [],measure_conversions:child.measure_conversions }, catalogs.units).quantity;
|
||||
} catch (error) {
|
||||
warnings.push(`${child.title}: ${(error as Error).message}`);
|
||||
readinessIssues.push(`${child.title} needs a weight equivalency for this recipe.`);
|
||||
continue;
|
||||
}
|
||||
inputWeightG += usedWeightG;
|
||||
calculatedYieldWeightG+=usedWeightG*(item.nutrition_retention_factor??1);
|
||||
add(batch, scale(childResult.batch, usedWeightG / childYieldG*(item.nutrition_retention_factor??1)));
|
||||
mappedWeightG += usedWeightG * childResult.completeness;
|
||||
mappedWeightG += usedWeightG * (item.nutrition_retention_factor??1) * childResult.completeness;
|
||||
if(childResult.completeness<.999)readinessIssues.push(`${child.title} has incomplete ingredient nutrition data.`);
|
||||
warnings.push(...childResult.warnings.map((warning) => `${child.title}: ${warning}`));
|
||||
}
|
||||
}
|
||||
@@ -103,16 +111,36 @@ export function calculateNutrition(recipe: Recipe, catalogs: Catalogs, stack: st
|
||||
yieldG = convertWithIngredientMeasures(recipe.yield.amount, "gram", { id: recipe.id, name: recipe.title, schema_version: 2, status: "active", categories: [],measure_conversions:recipe.measure_conversions }, catalogs.units).quantity;
|
||||
} catch {
|
||||
warnings.push("Recipe yield is not expressed as a mass; per-100 g values unavailable");
|
||||
readinessIssues.push("Total yield must be a weight or have a weight equivalency.");
|
||||
}
|
||||
let servingWeightG:number|undefined;
|
||||
if(recipe.yield.serving_size){
|
||||
try{
|
||||
servingWeightG=convertWithIngredientMeasures(recipe.yield.serving_size,"gram",{id:recipe.id,name:recipe.title,schema_version:2,status:"active",categories:[],measure_conversions:recipe.measure_conversions},catalogs.units).quantity;
|
||||
if(!(servingWeightG>0))throw new Error("non-positive serving size");
|
||||
}catch{
|
||||
readinessIssues.push("Serving size must be a positive amount that converts to weight.");
|
||||
}
|
||||
}else{
|
||||
readinessIssues.push("Enter an explicit serving size and unit.");
|
||||
}
|
||||
const servingsPerBatch=yieldG&&servingWeightG?yieldG/servingWeightG:undefined;
|
||||
const completeness=calculatedYieldWeightG > 0 ? mappedWeightG / calculatedYieldWeightG : 0;
|
||||
const uniqueReadinessIssues=[...new Set(readinessIssues)];
|
||||
return {
|
||||
batch,
|
||||
...(yieldG && yieldG > 0 ? { per100g: scale(batch, 100 / yieldG) } : {}),
|
||||
...(recipe.yield.servings ? { perServing: scale(batch, 1 / recipe.yield.servings) } : {}),
|
||||
...(yieldG&&servingWeightG ? { perServing: scale(batch, servingWeightG/yieldG) } : {}),
|
||||
...(recipe.yield.serving_size?{servingSize:recipe.yield.serving_size}:{}),
|
||||
...(servingWeightG?{servingWeightG}:{}),
|
||||
...(servingsPerBatch?{servingsPerBatch}:{}),
|
||||
inputWeightG,
|
||||
calculatedYieldWeightG,
|
||||
...(yieldG && yieldG > 0 ? { yieldWeightG: yieldG } : {}),
|
||||
mappedWeightG,
|
||||
completeness: calculatedYieldWeightG > 0 ? mappedWeightG / calculatedYieldWeightG : 0,
|
||||
completeness,
|
||||
warnings: [...new Set(warnings)],
|
||||
readinessIssues:uniqueReadinessIssues,
|
||||
labelReady:Boolean(yieldG&&recipe.yield.serving_size&&servingWeightG&&completeness>=.999&&uniqueReadinessIssues.length===0),
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function exportRecipe(database: DatabaseSync, id: string) {
|
||||
recipe.categories = JSON.parse(row.categories_json);
|
||||
recipe.tags = JSON.parse(row.tags_json);
|
||||
recipe.yield = { ...recipe.yield, amount: { ...recipe.yield.amount, quantity: row.yield_quantity, unit_id: row.yield_unit_id } };
|
||||
if (row.yield_servings != null) recipe.yield.servings = row.yield_servings; else delete recipe.yield.servings;
|
||||
delete recipe.yield.servings;
|
||||
if (row.yield_basis) recipe.yield.basis = row.yield_basis; else delete recipe.yield.basis;
|
||||
const structure = recipeStructure(database, id)!;
|
||||
const oldComponents = new Map((recipe.components ?? []).map((component: { id: string }) => [component.id, component]));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const alphabet = "0123456789abcdefghjkmnpqrstvwxyz";
|
||||
|
||||
function encode(value: number, length: number): string {
|
||||
let output = "";
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
output = alphabet[value % 32] + output;
|
||||
value = Math.floor(value / 32);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function newRecipeId(now = Date.now()): string {
|
||||
const random = new Uint8Array(16);
|
||||
crypto.getRandomValues(random);
|
||||
let suffix = "";
|
||||
for (let index = 0; index < 16; index += 1) suffix += alphabet[random[index] & 31];
|
||||
return `${encode(now, 10)}${suffix}`;
|
||||
}
|
||||
|
||||
export const isRecipeId = (value: string): boolean => /^[0-9a-hjkmnp-tv-z]{26}$/.test(value);
|
||||
@@ -186,7 +186,6 @@ export function createInventoryCount(
|
||||
.run(id, data.title.trim(), data.counted_at, data.notes?.trim() || null, now);
|
||||
|
||||
if (data.prepopulate !== false) {
|
||||
// Prepopulate active ingredients with zero quantities
|
||||
const defaultLocation = database
|
||||
.prepare("SELECT id FROM inventory_locations WHERE deleted_at IS NULL ORDER BY position LIMIT 1")
|
||||
.get() as { id: string } | undefined;
|
||||
@@ -233,7 +232,6 @@ export function saveInventoryCountItems(
|
||||
) {
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// Delete existing items and rebuild cleanly
|
||||
database.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(countId);
|
||||
|
||||
const insert = database.prepare(
|
||||
@@ -242,7 +240,6 @@ export function saveInventoryCountItems(
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
// Look up current purchase unit cost if available
|
||||
const priceRow = database
|
||||
.prepare(
|
||||
`SELECT (po.amount / (p.package_quantity * p.units_per_case)) as cost_per_unit
|
||||
|
||||
@@ -252,6 +252,8 @@ export function getDomainRecipe(
|
||||
: undefined;
|
||||
|
||||
const equipmentIds = recipeEquipmentRows.map((e) => e.equipment_id);
|
||||
const sourceYield = { ...(source.yield ?? {}) };
|
||||
delete sourceYield.servings;
|
||||
|
||||
return {
|
||||
...source,
|
||||
@@ -264,9 +266,8 @@ export function getDomainRecipe(
|
||||
...(row.station ? { station: row.station } : {}),
|
||||
...(row.auto_yield ? { auto_yield: true as const } : {}),
|
||||
yield: {
|
||||
...(source.yield ?? {}),
|
||||
...sourceYield,
|
||||
amount: { quantity: row.yield_quantity, unit_id: row.yield_unit_id },
|
||||
...(row.yield_servings != null ? { servings: row.yield_servings } : {}),
|
||||
...(row.yield_basis ? { basis: row.yield_basis as Recipe["yield"]["basis"] } : {}),
|
||||
},
|
||||
...(scaling ? { scaling } : {}),
|
||||
@@ -305,7 +306,6 @@ export function getRecipeCalculationContext(
|
||||
|
||||
collectReferences(domainRecipe);
|
||||
|
||||
// Recursively collect all referenced subrecipes
|
||||
while (subrecipeIdsToVisit.size > 0) {
|
||||
const nextId = subrecipeIdsToVisit.values().next().value!;
|
||||
subrecipeIdsToVisit.delete(nextId);
|
||||
@@ -421,7 +421,6 @@ export function getRecipeCalculationContext(
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch purchase items and prices for referenced ingredients
|
||||
const purchaseItemsMap = new Map<string, PurchaseItem>();
|
||||
if (ingredientIds.size > 0) {
|
||||
const placeholders = Array.from(ingredientIds).map(() => "?").join(",");
|
||||
@@ -470,7 +469,6 @@ export function getRecipeCalculationContext(
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch source mappings for referenced ingredients
|
||||
const sourceMappingsMap = new Map<string, SourceMapping>();
|
||||
if (ingredientIds.size > 0) {
|
||||
const placeholders = Array.from(ingredientIds).map(() => "?").join(",");
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export function createTestDatabase(migrations?: string[]): DatabaseSync {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
database.exec("PRAGMA foreign_keys = ON;");
|
||||
const directory = path.resolve(process.cwd(), "migrations");
|
||||
const files = migrations ?? fs.readdirSync(directory).filter((file) => file.endsWith(".sql")).sort();
|
||||
for (const file of files) database.exec(fs.readFileSync(path.join(directory, file), "utf8"));
|
||||
return database;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { usdaNutrition } from "./usda";
|
||||
|
||||
describe("USDA nutrition mapping", () => {
|
||||
it("retains additives and label nutrients including vitamin C", () => {
|
||||
const nutrition = usdaNutrition({ foodNutrients: [
|
||||
{ nutrient: { id: 1162 }, amount: 100000 },
|
||||
{ nutrient: { id: 1257 }, amount: 0 },
|
||||
{ nutrient: { id: 1235 }, amount: 0 },
|
||||
] });
|
||||
|
||||
expect(nutrition).toEqual({ vitamin_c_mg: 100000, trans_fat_g: 0, added_sugars_g: 0 });
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const NUTRIENTS: Record<string, string> = { "1008":"energy_kcal","2047":"energy_kcal","2048":"energy_kcal","1003":"protein_g","1004":"total_fat_g","1005":"carbohydrate_g","1079":"dietary_fiber_g","2000":"total_sugars_g","1093":"sodium_mg","1258":"saturated_fat_g","1253":"cholesterol_mg","1087":"calcium_mg","1089":"iron_mg","1092":"potassium_mg","1114":"vitamin_d_mcg" };
|
||||
const NUTRIENTS: Record<string, string> = { "1008":"energy_kcal","2047":"energy_kcal","2048":"energy_kcal","1003":"protein_g","1004":"total_fat_g","1005":"carbohydrate_g","1079":"dietary_fiber_g","2000":"total_sugars_g","1235":"added_sugars_g","1257":"trans_fat_g","1093":"sodium_mg","1258":"saturated_fat_g","1253":"cholesterol_mg","1087":"calcium_mg","1089":"iron_mg","1092":"potassium_mg","1114":"vitamin_d_mcg","1162":"vitamin_c_mg" };
|
||||
const PORTION_UNITS: Record<string, string> = { cup:"cup_us",tablespoon:"tablespoon_us",tbsp:"tablespoon_us",teaspoon:"teaspoon_us",tsp:"teaspoon_us",milliliter:"milliliter",ml:"milliliter","fluid ounce":"fluid_ounce_us","fl oz":"fluid_ounce_us" };
|
||||
|
||||
export async function fetchUsdaFood(fdcId: string, apiKey: string) {
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { openDatabase } from "../lib/database.ts";
|
||||
import { createMcpTools } from "./tools.ts";
|
||||
|
||||
export function createFormulationMcpServer() {
|
||||
const server = new McpServer({
|
||||
name: "formulation",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const getDb = () => {
|
||||
const db = openDatabase({ readOnly: false });
|
||||
if (!db) throw new Error("Formulation database unavailable at var/recipe-book.sqlite");
|
||||
return db;
|
||||
};
|
||||
|
||||
const tools = createMcpTools(getDb);
|
||||
|
||||
function wrap<T>(fn: () => T | Promise<T>) {
|
||||
return async () => {
|
||||
try {
|
||||
const result = await fn();
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err: any) {
|
||||
return { content: [{ type: "text" as const, text: `Error: ${err.message}` }], isError: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Search Recipes
|
||||
server.tool(
|
||||
"search_recipes",
|
||||
"Search recipes in the Formulation culinary database by keyword, category, or tag.",
|
||||
{
|
||||
query: z.string().optional().describe("Search keywords for title or ingredient"),
|
||||
category: z.string().optional().describe("Filter by category (e.g. 'sauce', 'bread')"),
|
||||
tag: z.string().optional().describe("Filter by tag"),
|
||||
limit: z.number().int().min(1).max(100).default(25).describe("Max results"),
|
||||
},
|
||||
async (args) => wrap(() => tools.searchRecipes(args))()
|
||||
);
|
||||
|
||||
// 2. Get Recipe
|
||||
server.tool(
|
||||
"get_recipe",
|
||||
"Get full recipe formulation with components, items, steps, and optional scaling.",
|
||||
{
|
||||
id: z.string().describe("Recipe ID"),
|
||||
scale_factor: z.number().positive().optional().describe("Scale multiplier (e.g. 2 for double batch)"),
|
||||
target_yield: z.number().positive().optional().describe("Target yield quantity to scale to"),
|
||||
target_yield_unit: z.string().optional().describe("Target yield unit if different from recipe"),
|
||||
},
|
||||
async (args) => wrap(() => tools.getRecipe(args))()
|
||||
);
|
||||
|
||||
// 3. Save Recipe
|
||||
server.tool(
|
||||
"save_recipe",
|
||||
"Create or update a recipe with validated components, items, and preparation steps.",
|
||||
{
|
||||
id: z.string().optional().describe("Recipe ID (omit to create new)"),
|
||||
title: z.string().min(1).describe("Recipe title"),
|
||||
summary: z.string().nullable().optional().describe("Brief culinary summary"),
|
||||
yield_quantity: z.number().positive().describe("Finished yield quantity"),
|
||||
yield_unit_id: z.string().describe("Yield unit (e.g. 'gram', 'each')"),
|
||||
yield_servings: z.number().positive().nullable().optional().describe("Number of servings"),
|
||||
yield_basis: z.string().optional().describe("Yield basis: theoretical, measured, estimated"),
|
||||
categories: z.array(z.string()).optional().default([]),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
components: z.array(z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().default("Main"),
|
||||
items: z.array(z.object({
|
||||
id: z.string().optional(),
|
||||
ingredient_id: z.string().optional(),
|
||||
subrecipe_id: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit_id: z.string(),
|
||||
percentage: z.number().nullable().optional(),
|
||||
basis_member: z.boolean().default(false),
|
||||
optional: z.boolean().default(false),
|
||||
notes: z.string().nullable().optional(),
|
||||
})),
|
||||
})).min(1).describe("Components with ingredient line items"),
|
||||
steps: z.array(z.object({
|
||||
id: z.string().optional(),
|
||||
instruction: z.string().min(1),
|
||||
equipment_ids: z.array(z.string()).optional(),
|
||||
})).min(1).describe("Ordered preparation steps"),
|
||||
notes: z.array(z.string()).optional().default([]),
|
||||
},
|
||||
async (args) => wrap(() => tools.saveRecipe(args as any))()
|
||||
);
|
||||
|
||||
// 4. Delete Recipe
|
||||
server.tool(
|
||||
"delete_recipe",
|
||||
"Archive a recipe from the active formulation library.",
|
||||
{ id: z.string().describe("Recipe ID to archive") },
|
||||
async (args) => wrap(() => tools.deleteRecipe(args))()
|
||||
);
|
||||
|
||||
// 5. Recipe Quality Audit
|
||||
server.tool(
|
||||
"audit_recipe_quality",
|
||||
"Run automated quality audit on all recipes (unpriced items, placeholder steps, missing yield basis).",
|
||||
{},
|
||||
async () => wrap(() => tools.auditRecipeQuality())()
|
||||
);
|
||||
|
||||
// 6. Calculate Recipe Cost
|
||||
server.tool(
|
||||
"calculate_recipe_cost",
|
||||
"Compute itemized ingredient costs, total batch cost, and cost per serving for a recipe.",
|
||||
{
|
||||
recipe_id: z.string().describe("Recipe ID"),
|
||||
currency: z.string().default("USD").describe("Currency code"),
|
||||
},
|
||||
async (args) => wrap(() => tools.calculateRecipeCost(args))()
|
||||
);
|
||||
|
||||
// 7. Calculate Recipe Nutrition
|
||||
server.tool(
|
||||
"calculate_recipe_nutrition",
|
||||
"Calculate nutrition facts (macros, vitamins) per 100g and per serving.",
|
||||
{
|
||||
recipe_id: z.string().describe("Recipe ID"),
|
||||
serving_size_g: z.number().positive().optional().describe("Serving size in grams"),
|
||||
},
|
||||
async (args) => wrap(() => tools.calculateRecipeNutrition(args))()
|
||||
);
|
||||
|
||||
// 8. Search Ingredients
|
||||
server.tool(
|
||||
"search_ingredients",
|
||||
"Search ingredients in the pantry catalog with cost and nutrition status.",
|
||||
{
|
||||
query: z.string().optional().describe("Search keywords"),
|
||||
category: z.string().optional().describe("Filter by category"),
|
||||
missing_cost: z.boolean().optional().describe("Show only ingredients missing purchase prices"),
|
||||
limit: z.number().int().min(1).max(100).default(25).describe("Max results"),
|
||||
},
|
||||
async (args) => wrap(() => tools.searchIngredients(args))()
|
||||
);
|
||||
|
||||
// 9. Get Ingredient
|
||||
server.tool(
|
||||
"get_ingredient",
|
||||
"Get detailed ingredient with density, equivalencies, aliases, and purchase prices.",
|
||||
{ id: z.string().describe("Ingredient ID") },
|
||||
async (args) => wrap(() => tools.getIngredient(args))()
|
||||
);
|
||||
|
||||
// 10. Save Ingredient
|
||||
server.tool(
|
||||
"save_ingredient",
|
||||
"Create or update an ingredient with category, aliases, and density data.",
|
||||
{
|
||||
id: z.string().optional().describe("Ingredient ID (omit to create new from name)"),
|
||||
name: z.string().min(1).describe("Ingredient name"),
|
||||
description: z.string().nullable().optional().describe("Culinary description"),
|
||||
categories: z.array(z.string()).optional().default([]),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
aliases: z.array(z.object({ name: z.string(), kind: z.string().optional() })).optional(),
|
||||
density: z.object({
|
||||
mass_quantity: z.number().positive(),
|
||||
mass_unit_id: z.string(),
|
||||
volume_quantity: z.number().positive(),
|
||||
volume_unit_id: z.string(),
|
||||
}).optional().describe("Density measurement for volume-to-weight conversions"),
|
||||
},
|
||||
async (args) => wrap(() => tools.saveIngredient(args))()
|
||||
);
|
||||
|
||||
// 11. Delete Ingredient
|
||||
server.tool(
|
||||
"delete_ingredient",
|
||||
"Archive an ingredient (fails safely if used in active recipes).",
|
||||
{ id: z.string().describe("Ingredient ID to archive") },
|
||||
async (args) => wrap(() => tools.deleteIngredient(args))()
|
||||
);
|
||||
|
||||
// 12. Purchasing & Prices
|
||||
server.tool(
|
||||
"list_purchase_items",
|
||||
"List purchase items with latest price observations.",
|
||||
{
|
||||
ingredient_id: z.string().optional().describe("Filter by ingredient ID"),
|
||||
status: z.string().optional().describe("Filter by status (active, discontinued)"),
|
||||
limit: z.number().int().min(1).max(200).default(50).describe("Max results"),
|
||||
},
|
||||
async (args) => wrap(() => tools.listPurchaseItems(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_purchase_item",
|
||||
"Get purchase item with full price history observations.",
|
||||
{ id: z.string().describe("Purchase Item ID") },
|
||||
async (args) => wrap(() => tools.getPurchaseItem(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"save_purchase_item",
|
||||
"Create or update a purchase item with package specification and initial price.",
|
||||
{
|
||||
id: z.string().optional().describe("Purchase Item ID"),
|
||||
ingredient_id: z.string().describe("Target ingredient ID"),
|
||||
name: z.string().min(1).describe("Product package name"),
|
||||
brand: z.string().nullable().optional(),
|
||||
supplier_id: z.string().nullable().optional(),
|
||||
supplier_sku: z.string().nullable().optional(),
|
||||
package_quantity: z.number().positive().describe("Package quantity"),
|
||||
package_unit_id: z.string().describe("Package unit ID (e.g. 'pound', 'gram')"),
|
||||
units_per_case: z.number().positive().default(1),
|
||||
usable_yield_factor: z.number().positive().default(1),
|
||||
initial_price: z.object({
|
||||
amount: z.number().positive(),
|
||||
currency: z.string().default("USD"),
|
||||
effective_at: z.string().optional(),
|
||||
}).optional(),
|
||||
},
|
||||
async (args) => wrap(() => tools.savePurchaseItem(args as any))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"record_price_observation",
|
||||
"Record a new price observation for an existing purchase item.",
|
||||
{
|
||||
purchase_item_id: z.string().describe("Purchase Item ID"),
|
||||
amount: z.number().positive().describe("Price amount"),
|
||||
currency: z.string().default("USD").describe("Currency code"),
|
||||
effective_at: z.string().optional().describe("Effective date (YYYY-MM-DD)"),
|
||||
},
|
||||
async (args) => wrap(() => tools.recordPriceObservation(args))()
|
||||
);
|
||||
|
||||
// 13. Convert Units
|
||||
server.tool(
|
||||
"convert_units",
|
||||
"Convert culinary units using ingredient-specific density and equivalency data.",
|
||||
{
|
||||
ingredient_id: z.string().optional().describe("Ingredient ID for cross-dimension conversion"),
|
||||
quantity: z.number().positive().describe("Quantity to convert"),
|
||||
from_unit: z.string().describe("Source unit ID (e.g. 'cup_us', 'gram')"),
|
||||
to_unit: z.string().describe("Target unit ID (e.g. 'gram', 'oz')"),
|
||||
},
|
||||
async (args) => wrap(() => tools.convertUnits(args))()
|
||||
);
|
||||
|
||||
// 14. Reference Catalogs
|
||||
server.tool(
|
||||
"list_units",
|
||||
"List all measurement units (mass, volume, count, time, temperature) with base conversion factors.",
|
||||
{
|
||||
dimension: z.string().optional().describe("Filter by dimension (mass, volume, count, time, temperature, length)"),
|
||||
system: z.string().optional().describe("Filter by system (metric, customary, si)"),
|
||||
},
|
||||
async (args) => wrap(() => tools.listUnits(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"list_equipment",
|
||||
"List kitchen equipment items with categories and notes.",
|
||||
{ category: z.string().optional().describe("Filter by category") },
|
||||
async (args) => wrap(() => tools.listEquipment(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"list_prep_actions",
|
||||
"List culinary prep actions (peel, trim, chop) and default yield factors.",
|
||||
{},
|
||||
async () => wrap(() => tools.listPrepActions())()
|
||||
);
|
||||
|
||||
// 15. Inventory Counts
|
||||
server.tool(
|
||||
"list_inventory_counts",
|
||||
"List inventory counting sessions with status and valuations.",
|
||||
{ status: z.enum(["all", "open", "completed"]).default("all").describe("Filter by status") },
|
||||
async (args) => wrap(() => tools.listInventoryCounts(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_inventory_count",
|
||||
"Get full inventory count sheet with counted items, locations, unit costs, and valuations.",
|
||||
{ id: z.string().describe("Inventory Count ID") },
|
||||
async (args) => wrap(() => tools.getInventoryCount(args))()
|
||||
);
|
||||
|
||||
// 16. Recipe Books / Collections
|
||||
server.tool(
|
||||
"list_recipe_books",
|
||||
"List recipe books (collections) with included recipe counts.",
|
||||
{},
|
||||
async () => wrap(() => tools.listRecipeBooks())()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_recipe_book",
|
||||
"Get recipe book details with ordered included recipes.",
|
||||
{ id: z.string().describe("Recipe Book ID") },
|
||||
async (args) => wrap(() => tools.getRecipeBook(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"save_recipe_book",
|
||||
"Create or update a recipe book with name, description, and included recipe IDs.",
|
||||
{
|
||||
id: z.string().optional().describe("Recipe book ID"),
|
||||
name: z.string().min(1).describe("Recipe book name"),
|
||||
description: z.string().nullable().optional().describe("Description"),
|
||||
recipe_ids: z.array(z.string()).optional().describe("Ordered list of recipe IDs"),
|
||||
},
|
||||
async (args) => wrap(() => tools.saveRecipeBook(args))()
|
||||
);
|
||||
|
||||
// 17. Archive & Restore
|
||||
server.tool(
|
||||
"list_archived_items",
|
||||
"List all archived (soft-deleted) items across recipes, ingredients, purchases, books, and inventory.",
|
||||
{},
|
||||
async () => wrap(() => tools.listArchivedItems())()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"restore_archived_items",
|
||||
"Restore archived items by ID and type (recipe, ingredient, purchase, book, inventory).",
|
||||
{
|
||||
items: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["recipe", "ingredient", "purchase", "book", "inventory"]),
|
||||
})).min(1).describe("List of items to restore"),
|
||||
},
|
||||
async (args) => wrap(() => tools.restoreArchived(args))()
|
||||
);
|
||||
|
||||
// 18. Database Export & Stats
|
||||
server.tool(
|
||||
"export_database_backup",
|
||||
"Export a complete JSON backup of the Formulation database (all 25 tables).",
|
||||
{},
|
||||
async () => wrap(() => tools.exportDatabaseBackup())()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_database_stats",
|
||||
"Get entity count statistics across all database tables.",
|
||||
{},
|
||||
async () => wrap(() => tools.getDatabaseStats())()
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function runStdioServer() {
|
||||
const server = createFormulationMcpServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Formulation MCP Server running on stdio");
|
||||
}
|
||||
+372
-370
@@ -1,371 +1,373 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Light.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Book.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Medium.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Black.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--ink: #050841;
|
||||
--ink-dark: #050841;
|
||||
--ink-medium: #202962;
|
||||
--ink-secondary: #3C4679;
|
||||
--muted: #8283a0;
|
||||
--muted-light: #a5a9c1;
|
||||
--paper: #ffffff;
|
||||
--card: #ffffff;
|
||||
--card-alt: #fafbfd;
|
||||
--card-paper: #f1f5fe;
|
||||
--line: #edf0f5;
|
||||
--line-subtle: #f4f5f8;
|
||||
--line-focus: #3d5df6;
|
||||
--blue: #3d5df6;
|
||||
--blue-hover: #1236e1;
|
||||
--blue-active: #001992;
|
||||
--blue-light: #647df8;
|
||||
--blue-tint: #f1f5fe;
|
||||
--blue-badge: #dbe4ff;
|
||||
--green: #40b49a;
|
||||
--green-dark: #20ab85;
|
||||
--green-tint: #abddd1;
|
||||
--orange: #f3a642;
|
||||
--red: #f63d48;
|
||||
--red-dark: #bc0020;
|
||||
--red-tint: #fff5f6;
|
||||
--purple: #6234f2;
|
||||
--pink-badge: #ffd2f5;
|
||||
--serif: "CircularCustCapNum", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--sans: "CircularCustCapNum", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
html {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
body, button, input, select, textarea {
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
body { margin:0; min-height:100vh; }
|
||||
a { color:inherit; text-decoration-thickness:1px; text-underline-offset:.2em; }
|
||||
.shell { width:min(1160px,calc(100% - 40px)); margin-inline:auto; }
|
||||
.site-header { min-height:76px; padding:0 max(20px,calc((100vw - 1160px)/2)); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:rgba(244,240,231,.94); position:sticky; top:0; z-index:10; backdrop-filter:blur(12px); }
|
||||
.brand { display:flex; gap:12px; align-items:center; font-family:var(--serif); font-size:1.15rem; font-weight:600; text-decoration:none; }
|
||||
.brand img { width:31px; height:31px; }
|
||||
nav { display:flex; gap:24px; }
|
||||
nav a { text-decoration:none; color:var(--muted); font-size:.9rem; font-weight:600; }
|
||||
nav a:hover { color:var(--ink); }
|
||||
main { min-height:calc(100vh - 150px); }
|
||||
h1,h2,h3 { font-family:var(--serif); line-height:1.08; margin-top:0; }
|
||||
h1 { font-size:clamp(3rem,7vw,6.4rem); letter-spacing:-.045em; }
|
||||
h2 { font-size:clamp(2rem,4vw,3.4rem); letter-spacing:-.03em; }
|
||||
h3 { font-size:1.35rem; }
|
||||
p { line-height:1.65; }
|
||||
.eyebrow { text-transform:uppercase; letter-spacing:.16em; font-size:.72rem; font-weight:600; color:var(--orange); margin:0 0 14px; }
|
||||
.button { display:inline-flex; padding:13px 20px; border:1px solid var(--ink); text-decoration:none; font-weight:600; font-size:.9rem; }
|
||||
.button.primary { color:var(--paper); background:var(--green); border-color:var(--green); }
|
||||
.section-block { padding-block:80px; }
|
||||
.section-heading { display:flex; justify-content:space-between; align-items:end; margin-bottom:32px; }
|
||||
.section-heading h2 { margin-bottom:0; }
|
||||
.recipe-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
|
||||
.recipe-card { min-height:245px; padding:25px; border:1px solid var(--line); background:var(--card); display:flex; flex-direction:column; }
|
||||
.recipe-card h2 { font-size:1.65rem; margin:35px 0 10px; }
|
||||
.recipe-card h2 a { text-decoration:none; }
|
||||
.recipe-card p { color:var(--muted); font-size:.92rem; margin-top:0; }
|
||||
.card-topline { display:flex; justify-content:space-between; text-transform:uppercase; font-size:.68rem; letter-spacing:.1em; color:var(--muted); }
|
||||
.tag-row { display:flex; flex-wrap:wrap; gap:8px 14px; margin-top:auto; }
|
||||
.tag-row a { color:var(--green); font-size:.78rem; text-decoration:none; }
|
||||
.page-heading { padding-block:85px 50px; border-bottom:1px solid var(--line); }.page-heading h1 { margin-bottom:15px; }.page-heading p:last-child { color:var(--muted); font-size:1.05rem; }
|
||||
.taxonomy-grid { padding-block:50px 90px; display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }.taxonomy-grid a { display:flex; flex-direction:column; gap:8px; padding:25px; border:1px solid var(--line); background:var(--card); text-decoration:none; }.taxonomy-grid span { color:var(--muted); font-size:.85rem; }
|
||||
.tag-cloud { padding-block:50px 90px; display:flex; gap:12px; flex-wrap:wrap; }.tag-cloud a { padding:10px 14px; border:1px solid var(--line); background:var(--card); text-decoration:none; }.tag-cloud small { color:var(--muted); margin-left:4px; }
|
||||
.recipe-page { padding-block:70px 100px; }.recipe-header { display:grid; grid-template-columns:1fr 280px; gap:80px; align-items:end; padding-bottom:55px; border-bottom:1px solid var(--line); }.recipe-header h1 { font-size:clamp(3rem,6vw,5.6rem); margin:18px 0; }.recipe-kicker { display:flex; gap:20px; text-transform:uppercase; color:var(--muted); letter-spacing:.12em; font-size:.7rem; }.recipe-facts { margin:0; display:grid; grid-template-columns:1fr 1fr; border-top:1px solid var(--ink); }.recipe-facts div { padding:17px 5px; border-bottom:1px solid var(--line); }.recipe-facts dt { color:var(--muted); font-size:.7rem; text-transform:uppercase; letter-spacing:.1em; }.recipe-facts dd { margin:5px 0 0; font-weight:600; }
|
||||
.workspace-command-bar { display:flex; align-items:center; gap:.75rem; margin-bottom:2rem; }.workspace-command-bar .back-link { margin-right:auto; }.mode-indicator { padding:.45rem .7rem; color:var(--muted); background:var(--card); border:1px solid var(--line); font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; }.mode-indicator.editing { color:#8b321f; background:#f3dfd4; }.primary-command { padding:.65rem .9rem; color:white; background:var(--green); text-decoration:none; font-weight:700; }.recipe-task-tabs { position:sticky; top:0; z-index:2; display:flex; gap:1.25rem; overflow-x:auto; margin-top:1.5rem; padding:.85rem 0; background:var(--paper); border-bottom:1px solid var(--line); }.recipe-task-tabs a { white-space:nowrap; color:var(--green); text-decoration:none; font-size:.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; }
|
||||
.notice { margin-top:28px; padding:16px 20px; border-left:4px solid var(--orange); background:#f3dfd4; }
|
||||
.calculator { margin-top:65px; }.calculator-heading { display:flex; justify-content:space-between; align-items:end; margin-bottom:25px; }.calculator-heading h2 { margin:0; }.basis-input { display:flex; flex-direction:column; gap:7px; font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }.input-with-unit { display:flex; align-items:center; border-bottom:1px solid var(--ink); color:var(--ink); }.input-with-unit input { width:120px; padding:8px 5px; border:0; background:transparent; font:600 1.1rem var(--sans); color:var(--ink); outline:none; }
|
||||
.quantity-control { display:inline-flex; align-items:center; border-bottom:1px solid var(--ink); }.quantity-control input { width:9rem; padding:.55rem .3rem; border:0; background:transparent; color:var(--ink); font:600 1.05rem var(--sans); }.quantity-control select { padding:.55rem .3rem; border:0; background:transparent; color:var(--green); font-weight:700; cursor:pointer; }.line-quantity { display:grid; grid-template-columns:7rem 6rem; justify-content:end; border-bottom:0; }.line-quantity input { width:100%; text-align:right; }.line-quantity select { width:100%; text-align:left; }
|
||||
.formula-component { margin-top:30px; }.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; } th,td { padding:14px 10px; border-bottom:1px solid var(--line); text-align:left; } th:not(:first-child),td:not(:first-child) { text-align:right; } th { color:var(--muted); font-size:.68rem; text-transform:uppercase; letter-spacing:.1em; }.recipe-ingredients { table-layout:fixed; }.recipe-ingredients th:first-child,.recipe-ingredients td:first-child { text-align:left; }.recipe-ingredients th:nth-last-child(2):not(:first-child),.recipe-ingredients td:nth-last-child(2):not(:first-child) { width:8rem; text-align:right; }.recipe-ingredients th:last-child,.recipe-ingredients td:last-child { width:14rem; text-align:right; }.basis-row td:first-child { color:var(--green); font-weight:600; }td small { display:block; color:var(--muted); margin-top:3px; }.muted { color:var(--muted); font-size:.75em; }.formula-total { display:flex; justify-content:flex-end; gap:60px; padding:20px 10px; background:var(--green); color:white; }
|
||||
.derived-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr)); gap:1rem; margin-top:2rem; }.derived-card { padding:1.15rem; background:var(--card); border:1px solid var(--line); }.derived-title { display:flex; justify-content:space-between; gap:1rem; align-items:end; padding-bottom:.6rem; border-bottom:3px solid var(--green); }.derived-title h3,.derived-title p { margin:0; }.derived-title > strong { font-size:.75rem; }.derived-card dl { margin:0; }.derived-card dl div { display:flex; justify-content:space-between; gap:1rem; padding:.4rem 0; border-bottom:1px solid var(--line); }.derived-card dt,.derived-card dd { margin:0; }.derived-card dd { font-weight:700; }.derived-batch { display:flex; justify-content:space-between; gap:1rem; }.derived-warning { color:#8b321f; font-size:.8rem; font-weight:700; }
|
||||
.nutrition-wrap { margin:0; }.nutrition-label { padding:.45rem .55rem .55rem; color:#111; background:#fff; border:1px solid #111; font-family:Arial,Helvetica,sans-serif; line-height:1.05; }.nutrition-title { display:block; border-bottom:.65rem solid #111; padding-bottom:.3rem; }.nutrition-title h2 { margin:0; font-family:Arial Black,Arial,Helvetica,sans-serif; font-size:2.65rem; line-height:.95; letter-spacing:-.12rem; }.nutrition-title p { margin:.18rem 0; font-size:.9rem; }.serving-size { display:flex; justify-content:space-between; gap:1rem; padding-top:.25rem; border-top:1px solid #111; font-size:1rem; }.amount-heading,.dv-heading { margin:0; font-size:.72rem; font-weight:700; }.calorie-row { display:flex; justify-content:space-between; align-items:end; border-bottom:.35rem solid #111; font-size:2rem; line-height:1; padding:.15rem 0; }.calorie-row strong:first-child { font-size:1.25rem; }.dv-heading { padding:.2rem 0; text-align:right; border-bottom:1px solid #111; }.nutrition-facts { margin:0; }.nutrition-facts div { display:flex; justify-content:space-between; gap:1rem; padding:.22rem .05rem; border-bottom:1px solid #777; font-size:.9rem; }.nutrition-facts dt,.nutrition-facts dd { margin:0; }.nutrition-facts dd { font-weight:700; }.nutrition-facts .protein { border-bottom:.35rem solid #111; }.dv-footnote { margin:.35rem 0 0; padding-left:.65rem; text-indent:-.55rem; font-size:.68rem; line-height:1.2; }.nutrition-source { margin:.75rem 0 0; font-size:.78rem; color:var(--muted); }
|
||||
.preparation,.notes { width:min(780px,100%); margin-top:80px; }.preparation ol { list-style:none; counter-reset:steps; padding:0; }.preparation li { counter-increment:steps; position:relative; padding:22px 0 22px 62px; border-top:1px solid var(--line); line-height:1.65; }.preparation li::before { content:counter(steps,decimal-leading-zero); position:absolute; left:5px; top:24px; color:var(--orange); font-size:.76rem; font-weight:600; }.preparation li.placeholder { color:var(--orange); }.notes ul { padding-left:20px; }.notes li { margin:10px 0; line-height:1.6; }
|
||||
.equipment-list { width:min(780px,100%); margin-top:60px; }.equipment-list h2 { margin-top:0; }.equipment-list ul { display:flex; flex-wrap:wrap; gap:.6rem; padding:0; list-style:none; }.equipment-list li { padding:.7rem .9rem; background:var(--card); border:1px solid var(--line); }.equipment-list small { display:block; max-width:18rem; margin-top:.2rem; color:var(--muted); }.step-equipment { display:block; margin-top:.35rem; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.06em; }
|
||||
.additional-details { width:min(780px,100%); margin-top:80px; }.additional-details h2 { margin-top:0; }.additional-details dl { margin:0; border-top:1px solid var(--ink); }.additional-details dl > div { display:grid; grid-template-columns:10rem 1fr; gap:1rem; padding:1rem .3rem; border-bottom:1px solid var(--line); }.additional-details dt { color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; }.additional-details dd { margin:0; font-weight:600; }.additional-details dd small { display:block; margin-top:.25rem; color:var(--muted); font-weight:400; }.additional-details ul { margin:0; padding:0; list-style:none; }.additional-details li + li { margin-top:.4rem; }
|
||||
.prose { max-width:850px; padding-block:80px 110px; }.prose h1 { font-size:clamp(3rem,6vw,5rem); }.prose h2 { font-size:2rem; margin-top:50px; }.prose p { color:var(--muted); }
|
||||
.review-toolbar { position:sticky; top:0; z-index:3; display:flex; gap:1rem; align-items:center; flex-wrap:wrap; padding:1rem; margin-bottom:2rem; background:var(--paper); border:1px solid var(--line); box-shadow:0 .5rem 1rem rgba(30,35,25,.08); }.review-toolbar div { margin-right:auto; }.review-toolbar small { display:block; color:var(--muted); }.review-toolbar input[type="search"],.review-toolbar button,.basis-input select { padding:.65rem .8rem; border:1px solid var(--line); background:var(--card); color:var(--ink); }.review-toolbar button { cursor:pointer; background:var(--green); color:white; font-weight:700; }.candidate-card { margin:0 0 1.5rem; padding:1.2rem; border:1px solid var(--line); background:var(--card); }.candidate-card legend { padding:0 .5rem; font:600 1.4rem var(--serif); }.candidate-card code { color:var(--muted); font:400 .72rem var(--sans); }.candidate-choice { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:1rem; padding:.8rem; border-bottom:1px solid var(--line); cursor:pointer; }.candidate-choice:has(input:checked) { background:#dce6d9; outline:2px solid var(--green); }.candidate-choice small { display:block; margin-top:.2rem; color:var(--muted); }.candidate-choice a { font-size:.78rem; }.candidate-choice.none { border-bottom:0; }
|
||||
.product-meta { color:var(--muted); font-size:.82rem; text-transform:capitalize; }
|
||||
.other-choice { display:flex; gap:1rem; align-items:center; justify-content:space-between; padding:.8rem; border-bottom:1px solid var(--line); }.other-choice select { min-width:min(25rem,55vw); padding:.6rem; border:1px solid var(--line); background:var(--paper); }
|
||||
.quality-summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(9rem,1fr)); gap:1rem; padding-bottom:1rem; }.quality-summary article { display:flex; flex-direction:column; padding:1.1rem; background:var(--card); border:1px solid var(--line); }.quality-summary strong { font:600 2.25rem var(--serif); color:var(--green); }.quality-summary span { color:var(--muted); font-size:.78rem; }.quality-table td:first-child { text-align:left; }.quality-table small { display:block; }.quality-table td:not(:first-child) { font-size:.82rem; }
|
||||
.app-launcher { display:grid; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)); gap:1rem; padding-bottom:6rem; }.app-launcher a { position:relative; min-height:14rem; padding:1.4rem; color:var(--ink); background:var(--card); border:1px solid var(--line); text-decoration:none; }.app-launcher h2 { margin:.45rem 0; }.app-launcher strong { position:absolute; top:1.2rem; right:1.2rem; font:600 1.5rem var(--serif); color:var(--green); }.app-launcher p { color:var(--muted); }.entity-list { padding-bottom:6rem; }.entity-list-head,.entity-list > a { display:grid; grid-template-columns:minmax(16rem,2fr) repeat(3,minmax(6rem,1fr)); align-items:center; gap:1rem; padding:.8rem 1rem; border-bottom:1px solid var(--line); }.entity-list-head { color:var(--muted); font-size:.68rem; text-transform:uppercase; letter-spacing:.08em; }.entity-list > a { color:var(--ink); text-decoration:none; }.entity-list > a:hover { background:var(--card); }.entity-list small { display:block; color:var(--muted); }.empty-state { padding:3rem; text-align:center; background:var(--card); border:1px solid var(--line); }
|
||||
.ingredient-list-tools { display:flex; justify-content:flex-end; padding-block:1rem; }.filter-menu { position:relative; }.filter-menu summary { padding:.65rem .85rem; color:white; background:var(--green); cursor:pointer; font-weight:700; list-style:none; }.filter-menu summary::-webkit-details-marker { display:none; }.filter-menu form { position:absolute; z-index:4; right:0; width:min(20rem,calc(100vw - 2rem)); padding:1rem; background:var(--card); border:1px solid var(--line); box-shadow:0 .75rem 2rem rgba(30,35,25,.14); }.filter-menu label { display:flex; align-items:center; gap:.55rem; padding:.35rem 0; }.filter-menu fieldset { margin:.75rem 0; padding:.75rem; border:1px solid var(--line); }.filter-menu legend { color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.06em; }.filter-menu form > div { display:flex; align-items:center; gap:1rem; }.filter-menu button { padding:.6rem .8rem; color:white; background:var(--green); border:0; cursor:pointer; font-weight:700; }.ingredient-entity-list .entity-list-head,.ingredient-entity-list > a { grid-template-columns:minmax(16rem,1fr) 7rem; }.attention-reasons { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.4rem; }.attention-reasons em { padding:.18rem .4rem; color:#8b321f; background:#f3dfd4; font-size:.68rem; font-style:normal; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.directory-workspace { padding-block:28px 72px; }.workspace-pills { display:flex; align-items:center; flex-wrap:wrap; gap:10px; overflow:visible; padding:0 0 28px; }.workspace-pills > a { display:inline-flex; align-items:center; gap:8px; flex:none; min-height:42px; padding:7px 13px; color:rgba(0,0,0,0.87); background:white; border:1px solid #e5e7ef; border-radius:999px; text-decoration:none; font-weight:400; box-shadow:0 1px 2px rgba(15,23,42,.03); }.workspace-pills > a.active { background:#DBE4FF; border-color:#DBE4FF; }.workspace-pills > a small { color:#a0a7ba; font-size:.8rem; font-weight:500; }.workspace-pill-icon { display:grid; place-items:center; width:22px; height:22px; color:white; background:#48558d; border-radius:50%; font-size:.7rem; }.workspace-pill-icon.ingredient,.workspace-pill-icon.purchase { background:#489d96; }.workspace-pill-icon.book { background:#6847ed; }.workspace-pills .filter-menu { flex:none; margin-left:auto; }.workspace-pills .filter-menu summary { color:#172033; background:transparent; white-space:nowrap; }.ingredient-directory { overflow:hidden; background:white; border:1px solid #eef0f5; }.ingredient-selection-bar { display:grid; grid-template-columns:28px 1fr auto; align-items:center; min-height:58px; gap:18px; padding:0 22px; border-bottom:1px solid #eef0f5; }.ingredient-selection-bar input,.ingredient-directory-row > input { width:17px; height:17px; margin:0; accent-color:#4868f7; }.ingredient-selection-bar strong { color:#5366fa; font-size:.9rem; }.ingredient-selection-bar button { color:#64748b; background:transparent; border:0; cursor:pointer; }.ingredient-directory-row { display:grid; grid-template-columns:28px 34px minmax(14rem,1fr) 10rem 28px; align-items:center; min-height:60px; gap:18px; padding:0 22px; border-bottom:1px solid #eef0f5; }.ingredient-directory-row:last-child { border-bottom:0; }.ingredient-directory-row:hover { background:#f8faff; }.ingredient-directory-row.selected { background:#edf2ff; }.ingredient-type-icon { display:grid; place-items:center; width:26px; height:26px; color:white; background:#489d96; border-radius:50%; font-size:.65rem; }.ingredient-directory-name { color:#111a49; text-decoration:none; }.ingredient-directory-name strong { font-size:.9rem; }.ingredient-directory-name small { display:flex; gap:5px; margin-top:3px; }.ingredient-directory-name em { padding:2px 5px; color:#9a3412; background:#fff0e8; border-radius:3px; font-size:.62rem; font-style:normal; font-weight:700; text-transform:uppercase; }.ingredient-usage { color:#8b93a7; font-size:.82rem; text-align:right; }.ingredient-row-menu { color:#a0a7ba; font-size:1.45rem; line-height:1; text-align:center; text-decoration:none; }
|
||||
.workspace-directory-list { overflow:hidden; background:white; border:1px solid #eef0f5; }.workspace-directory-heading,.workspace-directory-row { display:grid; grid-template-columns:34px minmax(16rem,1fr) 9rem 10rem 28px; align-items:center; gap:18px; padding:0 22px; }.workspace-directory-heading { min-height:42px; color:#8b93a7; border-bottom:1px solid #eef0f5; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; }.workspace-directory-heading strong { grid-column:2; }.workspace-directory-row { min-height:64px; color:#111a49; border-bottom:1px solid #eef0f5; text-decoration:none; }.workspace-directory-row:last-child { border-bottom:0; }.workspace-directory-row:hover { background:#f8faff; }.workspace-directory-row > span:nth-child(2) { display:flex; flex-direction:column; min-width:0; }.workspace-directory-row small { overflow:hidden; color:#8b93a7; text-overflow:ellipsis; white-space:nowrap; }.workspace-directory-row b { color:#a0a7ba; font-size:1.35rem; text-align:center; }.workspace-directory-heading:has(+ .books),.workspace-directory-row.books { grid-template-columns:34px minmax(16rem,1fr) 9rem 28px; }.workspace-directory-heading:has(+ .purchases),.workspace-directory-row.purchases { grid-template-columns:34px minmax(14rem,1fr) minmax(10rem,1fr) 9rem 7rem 28px; }
|
||||
.workspace-search-tools { display:flex; align-items:flex-start; gap:10px; margin-bottom:18px; }.workspace-global-search { display:flex; align-items:center; gap:10px; flex:1; max-width:680px; min-height:44px; padding:0 14px; background:white; border:1px solid #dfe3ec; border-radius:7px; }.workspace-global-search:focus-within { border-color:#7185ec; box-shadow:0 0 0 3px #e9efff; }.workspace-global-search > span { color:#818aa0; font-size:1.35rem; }.workspace-global-search input[type="search"] { flex:1; min-width:0; padding:10px 0; color:#111a49; background:transparent; border:0; outline:0; font:inherit; }.workspace-global-search a { color:#818aa0; font-size:1.35rem; text-decoration:none; }.search-type-filter { position:relative; }.search-type-filter summary { min-height:44px; padding:11px 14px; color:#172033; background:white; border:1px solid #dfe3ec; border-radius:7px; cursor:pointer; font-weight:700; list-style:none; white-space:nowrap; }.search-type-filter summary::-webkit-details-marker { display:none; }.search-type-filter form { position:absolute; z-index:5; right:0; width:220px; padding:12px; background:white; border:1px solid #dfe3ec; border-radius:7px; box-shadow:0 .75rem 2rem rgba(30,35,25,.14); }.search-type-filter label { display:flex; align-items:center; gap:8px; padding:6px 2px; }.search-type-filter form > div { display:flex; align-items:center; gap:12px; margin-top:10px; }.search-type-filter button { padding:7px 10px; color:white; background:#48558d; border:0; border-radius:5px; cursor:pointer; font-weight:700; }.workspace-search-results > p { color:#687086; font-size:.86rem; }.workspace-search-results > div { overflow:hidden; background:white; border:1px solid #eef0f5; }.workspace-search-results > div > a { display:grid; grid-template-columns:34px minmax(12rem,1fr) 9rem 24px; align-items:center; gap:16px; min-height:62px; padding:0 22px; color:#111a49; border-bottom:1px solid #eef0f5; text-decoration:none; }.workspace-search-results > div > a:last-child { border-bottom:0; }.workspace-search-results > div > a:hover { background:#f8faff; }.workspace-search-results > div > a > span:nth-child(2) { display:flex; flex-direction:column; min-width:0; }.workspace-search-results small { overflow:hidden; color:#8b93a7; text-overflow:ellipsis; white-space:nowrap; }.workspace-search-results em { color:#8b93a7; font-size:.75rem; font-style:normal; text-align:right; text-transform:uppercase; }.workspace-search-results b { color:#a0a7ba; font-size:1.3rem; }
|
||||
.workspace-tabs { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1.5rem; }.workspace-tabs a { padding:.55rem .75rem; color:var(--ink); background:var(--card); border:1px solid var(--line); text-decoration:none; }.ingredient-workspace { display:grid; gap:1.25rem; padding-bottom:6rem; }.workspace-panel { scroll-margin-top:1rem; padding:1.25rem; background:var(--card); border:1px solid var(--line); }.workspace-panel h2 { margin-top:0; }.workspace-panel > label { display:grid; grid-template-columns:minmax(9rem,1fr) 3fr; gap:1rem; align-items:center; margin:.75rem 0; color:var(--muted); }.workspace-panel input,.workspace-panel textarea,.workspace-panel select { padding:.65rem; color:var(--ink); background:var(--paper); border:1px solid var(--line); font:1rem var(--sans); }.inline-form { display:flex; flex-wrap:wrap; gap:.55rem; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--line); }.inline-form button { padding:.7rem; color:white; background:var(--green); border:1px solid var(--green); cursor:pointer; }.purchase-row { display:grid; grid-template-columns:minmax(12rem,1fr) auto auto; gap:1rem; align-items:center; padding:.7rem 0; border-bottom:1px solid var(--line); }.purchase-row small { display:block; color:var(--muted); }
|
||||
.panel-intro { color:var(--muted); }.ingredient-nutrition { margin-top:1rem; padding:1rem; background:var(--paper); border:1px solid var(--line); }.nutrition-mapping-head { display:flex; justify-content:space-between; gap:1rem; align-items:start; padding-bottom:.75rem; border-bottom:3px solid var(--green); }.nutrition-mapping-head small { display:block; margin-top:.2rem; color:var(--muted); text-transform:capitalize; }.nutrition-mapping-head a { white-space:nowrap; }.ingredient-nutrition dl { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); margin:0; }.ingredient-nutrition dl div { display:flex; justify-content:space-between; gap:1rem; padding:.55rem; border-bottom:1px solid var(--line); }.ingredient-nutrition dt,.ingredient-nutrition dd { margin:0; }.ingredient-nutrition dd { font-weight:700; }.ingredient-nutrition footer { display:flex; flex-wrap:wrap; gap:.5rem 1.2rem; padding-top:.75rem; color:var(--muted); font-size:.72rem; }
|
||||
.purchase-editor { border-top:1px solid var(--line); }.purchase-editor:last-child { border-bottom:1px solid var(--line); }.purchase-editor > summary { display:flex; justify-content:space-between; gap:1rem; align-items:center; padding:1rem .25rem; cursor:pointer; }.purchase-editor > summary > span:last-child { text-align:right; }.purchase-editor summary small { display:block; margin-top:.2rem; color:var(--muted); }.add-purchase > summary { color:var(--green); font-weight:700; }.purchase-item-form { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.85rem; padding:1rem; margin-bottom:1rem; background:var(--paper); border:1px solid var(--line); }.purchase-item-form label { display:flex; flex-direction:column; gap:.3rem; min-width:0; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; }.purchase-item-form .purchase-name { grid-column:span 2; }.purchase-item-form input,.purchase-item-form select { width:100%; }.input-suffix { display:flex; align-items:center; }.input-suffix input { min-width:0; }.input-suffix > span { padding:.65rem; color:var(--ink); background:var(--card); border:1px solid var(--line); border-left:0; }.purchase-rate { grid-column:span 3; display:flex; align-items:center; gap:1rem; margin:0; padding:.65rem; background:var(--card); }.purchase-rate span { color:var(--muted); }.purchase-form-actions { display:flex; justify-content:flex-end; align-items:end; }.purchase-form-actions button { padding:.7rem; color:white; background:var(--green); border:1px solid var(--green); cursor:pointer; font-weight:700; }.price-history { grid-column:1/-1; }.price-history summary { cursor:pointer; color:var(--green); font-size:.8rem; font-weight:700; }.price-history table { margin-top:.5rem; }.price-history td { padding:.4rem; font-size:.8rem; }.price-history td:last-child { text-align:right; }
|
||||
.usda-id-form { display:flex; flex-wrap:wrap; gap:.65rem; align-items:end; }.usda-id-form label { display:flex; flex-direction:column; gap:.35rem; }.usda-id-form input { min-width:15rem; }.usda-id-form button { padding:.7rem; color:white; background:var(--green); border:1px solid var(--green); cursor:pointer; }.usda-id-form a { padding:.65rem 0; }
|
||||
.editor-layout { display:grid; grid-template-columns:minmax(0,2fr) minmax(16rem,1fr); gap:2rem; padding-bottom:6rem; }.editor-form fieldset { display:grid; gap:1rem; margin:0 0 1.5rem; padding:1.25rem; border:1px solid var(--line); background:var(--card); }.editor-form legend { padding:0 .5rem; font:600 1.35rem var(--serif); }.editor-form label { display:flex; flex-direction:column; gap:.4rem; color:var(--muted); font-size:.76rem; text-transform:uppercase; letter-spacing:.06em; }.editor-form input,.editor-form textarea,.editor-form select { width:100%; padding:.7rem; color:var(--ink); background:var(--paper); border:1px solid var(--line); font:1rem var(--sans); text-transform:none; letter-spacing:normal; }.editor-row { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }.editor-actions { display:flex; flex-wrap:wrap; gap:.75rem; }.editor-actions button { padding:.8rem 1rem; border:1px solid var(--green); background:var(--green); color:white; font-weight:700; cursor:pointer; }.editor-actions .secondary { background:transparent; color:var(--green); }.success-notice { margin-bottom:1rem; padding:1rem; color:#204f31; background:#dcebdc; border-left:4px solid #39734c; }
|
||||
.structure-editor button { padding:.55rem .7rem; border:1px solid var(--line); background:var(--card); cursor:pointer; }.structure-editor .section-heading > button,.add-line button { color:white; background:var(--green); }.component-editor { margin:0 0 1.5rem; padding:1rem; border:1px solid var(--line); }.component-editor legend { padding:0 .5rem; font:600 1.25rem var(--serif); }.editor-inline,.add-line { display:flex; gap:.5rem; margin-bottom:1rem; }.editor-inline input,.add-line select,.editor-items input[type="number"],.editor-items select,.method-editor textarea { width:100%; padding:.55rem; color:var(--ink); background:var(--paper); border:1px solid var(--line); }.editor-inline input { font-weight:700; }.editor-items { min-width:60rem; table-layout:fixed; }.editor-items th,.editor-items td { vertical-align:middle; text-align:left; }.editor-items th:nth-child(2),.editor-items td:nth-child(2) { width:7rem; text-align:right; }.editor-items td:nth-child(2) input { text-align:right; }.editor-items th:nth-child(3),.editor-items td:nth-child(3) { width:12rem; }.editor-items th:nth-child(4),.editor-items td:nth-child(4) { width:9rem; }.editor-items th:nth-child(5),.editor-items td:nth-child(5) { width:6rem; text-align:center; }.editor-items th:last-child,.editor-items td:last-child { width:8.5rem; text-align:right; }.editor-items input[type="number"],.editor-items select { min-width:0; }.editor-items td:last-child .row-actions button { width:2.1rem; height:2.1rem; padding:0; }.row-actions { display:flex; gap:.25rem; justify-content:flex-end; }.structure-editor button.danger { color:#8b321f; }.inline-check { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; white-space:nowrap; font-size:.76rem; }.inline-check input { width:auto; min-width:0; margin:0; }.secondary-action { margin-bottom:1.5rem; }.method-editor ol { padding:0; list-style:none; counter-reset:method; }.method-editor li { display:grid; grid-template-columns:1fr auto; gap:.5rem; margin-bottom:.75rem; counter-increment:method; }.method-editor li::before { content:counter(method); display:none; }
|
||||
.editor-entity-link { color:var(--green); text-decoration-thickness:1px; text-underline-offset:3px; }
|
||||
@media (max-width:900px) { .recipe-grid,.taxonomy-grid { grid-template-columns:repeat(2,1fr); }.recipe-header { grid-template-columns:1fr; gap:25px; }.recipe-facts { max-width:500px; }.purchase-item-form { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:620px) { .site-header { align-items:flex-start; padding-block:17px; }.site-header nav { gap:8px 14px; flex-wrap:wrap; justify-content:flex-end; }.brand span { display:none; }.recipe-grid,.taxonomy-grid { grid-template-columns:1fr; }.calculator-heading { align-items:flex-start; gap:25px; flex-direction:column; }.formula-total { justify-content:space-between; gap:15px; }.recipe-page { padding-top:45px; }.recipe-header h1 { font-size:3.2rem; }.entity-list-head { display:none; }.entity-list > a { grid-template-columns:1fr 1fr; }.editor-layout { grid-template-columns:1fr; }.workspace-panel > label { grid-template-columns:1fr; }.purchase-row { grid-template-columns:1fr; }.purchase-item-form { grid-template-columns:1fr; }.purchase-item-form .purchase-name,.purchase-rate { grid-column:auto; }.purchase-editor > summary { align-items:flex-start; }.purchase-editor > summary > span:last-child { text-align:left; } }
|
||||
|
||||
/* Conventional application theme */
|
||||
html { line-height:1.5; }
|
||||
body { background:var(--paper); }
|
||||
.shell { width:min(1180px,calc(100% - 32px)); }
|
||||
.site-header { min-height:64px; padding-inline:max(16px,calc((100vw - 1180px)/2)); background:rgba(255,255,255,.96); box-shadow:0 1px 2px rgba(15,23,42,.04); }
|
||||
.brand { gap:10px; font-size:1rem; font-weight:700; }
|
||||
.brand img { width:28px; height:28px; }
|
||||
nav { gap:20px; }
|
||||
nav a { font-size:.875rem; }
|
||||
h1,h2,h3 { font-family:var(--sans); line-height:1.2; letter-spacing:-.02em; }
|
||||
h1 { font-size:clamp(2rem,4vw,3rem); }
|
||||
h2 { font-size:clamp(1.5rem,2.5vw,2rem); }
|
||||
h3 { font-size:1.125rem; }
|
||||
.eyebrow { margin-bottom:8px; color:var(--muted); font-size:.7rem; letter-spacing:.1em; }
|
||||
.button,.primary-command { border-radius:6px; }
|
||||
.button { padding:10px 16px; font-size:.875rem; }
|
||||
.button.primary,.primary-command { background:var(--green); border-color:var(--green); }
|
||||
.section-block { padding-block:48px; }
|
||||
.section-heading { margin-bottom:20px; align-items:center; }
|
||||
.recipe-grid { gap:16px; }
|
||||
.recipe-card,.taxonomy-grid a,.candidate-card,.workspace-panel,.derived-card,.app-launcher a { border-radius:8px; box-shadow:0 1px 2px rgba(15,23,42,.04); }
|
||||
.recipe-card { min-height:190px; padding:20px; }
|
||||
.recipe-card h2 { margin:24px 0 8px; font-size:1.25rem; }
|
||||
.page-heading { padding-block:48px 28px; }
|
||||
.page-heading h1 { margin-bottom:8px; }
|
||||
.page-heading p:last-child { font-size:.95rem; }
|
||||
.recipe-page { padding-block:48px 72px; }
|
||||
.recipe-header { grid-template-columns:1fr 260px; gap:48px; padding-bottom:36px; }
|
||||
.recipe-header h1 { margin:12px 0; font-size:clamp(2rem,4vw,3rem); }
|
||||
.workspace-command-bar { margin-bottom:1.25rem; }
|
||||
.mode-indicator,.workspace-tabs a,.primary-command { border-radius:6px; }
|
||||
.recipe-task-tabs { top:64px; background:rgba(248,250,252,.96); }
|
||||
.calculator { margin-top:44px; }
|
||||
th,td { padding:11px 10px; }
|
||||
th { font-size:.7rem; letter-spacing:.06em; }
|
||||
.formula-total { padding:14px 10px; background:#eef2ff; color:var(--ink); border-bottom:1px solid var(--line); }
|
||||
.equipment-list li { border-radius:6px; }
|
||||
.preparation,.notes,.additional-details { margin-top:56px; }
|
||||
.review-toolbar { top:64px; border-radius:8px; background:rgba(248,250,252,.96); }
|
||||
.entity-list > a:hover { background:#f1f5f9; }
|
||||
.filter-menu summary,.filter-menu button,.inline-form button,.purchase-form-actions button,.editor-actions button { border-radius:6px; }
|
||||
.workspace-panel { padding:1.25rem; }
|
||||
.workspace-panel input,.workspace-panel textarea,.workspace-panel select,.editor-form input,.editor-form textarea,.editor-form select,.editor-inline input,.add-line select,.editor-items input[type="number"],.editor-items select,.method-editor textarea { border-radius:6px; background:white; }
|
||||
.component-editor { border-radius:8px; background:var(--card); }
|
||||
.structure-editor button { border-radius:6px; }
|
||||
.success-notice,.notice { border-radius:6px; }
|
||||
.purchase-item-form,.ingredient-nutrition { border-radius:6px; background:#f8fafc; }
|
||||
.app-launcher a { min-height:11rem; }
|
||||
|
||||
@media (max-width:620px) {
|
||||
.shell { width:min(100% - 24px,1180px); }
|
||||
.recipe-page { padding-top:32px; }
|
||||
.recipe-header h1 { font-size:2.25rem; }
|
||||
.page-heading { padding-block:36px 24px; }
|
||||
}
|
||||
.workspace-new-menu { position:relative; margin-left:auto; }
|
||||
.workspace-new-menu > summary { display:flex; align-items:center; gap:5px; min-height:44px; padding:9px 16px; color:white; background:#4868f7; border-radius:22px; cursor:pointer; font-weight:700; list-style:none; white-space:nowrap; }
|
||||
.workspace-new-menu > summary::-webkit-details-marker { display:none; }
|
||||
.workspace-new-menu > nav { position:absolute; z-index:6; top:52px; right:0; width:260px; padding:10px 0; background:white; border:1px solid #dfe3ec; border-radius:7px; box-shadow:0 .75rem 2rem rgba(30,35,25,.16); }
|
||||
.workspace-new-menu > nav a { display:grid; grid-template-columns:28px 1fr; align-items:center; gap:14px; min-height:54px; padding:8px 20px; color:#111a49; text-decoration:none; }
|
||||
.workspace-new-menu > nav a:hover { background:#f8faff; }
|
||||
.create-entity { max-width:720px; padding-bottom:72px; }
|
||||
.entity-detail-shell,.recipe-detail-shell { width:min(1500px,calc(100% - 48px)); margin:0 auto; padding:24px 0 72px; color:#101a4a; }
|
||||
.entity-detail-header { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; min-height:108px; border-bottom:1px solid #e8eaf0; }.entity-detail-header h1 { margin:8px 0 2px; font:700 clamp(1.7rem,2.4vw,2.35rem) var(--sans); letter-spacing:-.035em; }.entity-detail-header p { margin:0; color:#858da2; }.entity-breadcrumb a { color:#26346d; font-size:.82rem; text-decoration:none; }.identity-edit { position:relative; }.identity-edit > summary { padding:8px 15px; color:#4868f7; border:1px solid #4868f7; border-radius:20px; cursor:pointer; font-weight:700; list-style:none; }.identity-edit > summary::-webkit-details-marker { display:none; }.identity-edit > form { position:absolute; z-index:8; right:0; width:min(390px,calc(100vw - 32px)); padding:18px; background:white; border:1px solid #dfe3ec; border-radius:7px; box-shadow:0 .75rem 2rem rgba(30,35,25,.16); }.identity-edit label { display:grid; gap:4px; margin-bottom:10px; color:#737c91; font-size:.75rem; }.identity-edit input,.identity-edit select,.identity-edit textarea { width:100%; padding:9px; border:1px solid #dfe3ec; }.identity-edit button { padding:9px 12px; color:white; background:#4868f7; border:0; border-radius:5px; font-weight:700; }.entity-notice { margin:14px 0; }.entity-detail-grid { display:grid; grid-template-columns:minmax(0,1.02fr) minmax(0,.98fr); min-height:720px; }.entity-primary { padding:48px 42px 40px 0; border-right:1px solid #e8eaf0; }.entity-secondary { min-width:0; padding-left:32px; }.entity-section { margin-bottom:54px; }.entity-section h2,.entity-tab-panel h2 { margin:0 0 8px; font:700 1.35rem var(--sans); }.prep-action-table { margin-top:34px; }.prep-action-table th,.prep-action-table td { padding:13px 8px; text-align:left; border-bottom:1px solid #edf0f5; }.prep-action-table th { color:#9098aa; font-size:.72rem; font-weight:500; }.inline-editor { margin-top:20px; }.inline-editor > summary { color:#4868f7; cursor:pointer; font-weight:700; list-style:none; }.additional-card { min-height:260px; padding:28px; border:1px solid #e8eaf0; }.additional-card > details { padding:16px 0; }.additional-card summary { cursor:pointer; font-weight:700; }.additional-card summary small { margin-left:5px; color:#9aa2b5; }.additional-card ul { padding-left:20px; }.additional-card li { display:flex; justify-content:space-between; gap:10px; padding:6px 0; }.entity-tab-panel { display:none; padding:42px 0; background:transparent; border:0; }.entity-tab-panel.active { display:block; }.equivalency-list p { display:grid; grid-template-columns:1fr auto 1fr; gap:12px; padding:14px 4px; border-bottom:1px solid #e8eaf0; }.equivalency-list p small { grid-column:1/-1; color:#8b93a7; }.entity-secondary .purchase-item-form { grid-template-columns:repeat(2,minmax(0,1fr)); }.entity-secondary .purchase-name,.entity-secondary .purchase-rate { grid-column:1/-1; }
|
||||
.recipe-detail-shell { width:min(1600px,calc(100% - 48px)); }.recipe-detail-shell .entity-detail-header { min-height:92px; }.recipe-overview-strip { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; padding:18px 0; }.recipe-overview-form { flex:1; }.recipe-overview-form > details > summary { color:#4868f7; cursor:pointer; font-weight:700; }.recipe-overview-form fieldset { max-width:720px; margin-top:15px; }.recipe-yield-summary { display:flex; align-items:baseline; gap:12px; min-width:260px; padding:12px 16px; border:1px solid #e8eaf0; }.recipe-yield-summary span,.recipe-yield-summary small { color:#8b93a7; }.recipe-structure-workspace { border-top:1px solid #e8eaf0; }.recipe-structure-workspace .structure-editor { display:grid; grid-template-columns:minmax(0,1fr) minmax(420px,.92fr); column-gap:32px; }.recipe-structure-workspace .section-heading { grid-column:1/-1; }.recipe-structure-workspace .component-editor,.recipe-structure-workspace > .structure-editor > .secondary-action { grid-column:1; }.recipe-structure-workspace .method-editor { grid-column:2; grid-row:2 / span 40; min-width:0; padding-left:32px; border-left:1px solid #e8eaf0; }.recipe-structure-workspace .component-editor { border-color:#e8eaf0; background:white; }.recipe-structure-workspace .editor-items { min-width:46rem; }.recipe-structure-workspace .method-editor li { padding:15px; border:1px solid #e8eaf0; }.recipe-history { margin-top:30px; padding:18px; border:1px solid #e8eaf0; }.recipe-history summary { cursor:pointer; font-weight:700; }.recipe-history li span,.recipe-history li small { display:block; color:#8b93a7; }
|
||||
.immersive-main { min-height:100vh; background:#fff; }.immersive-main .entity-detail-shell,.immersive-main .recipe-detail-shell { width:100%; max-width:none; margin:0; padding:0 0 72px; }.detail-utility { display:flex; align-items:center; justify-content:flex-end; gap:18px; min-height:58px; padding:7px 28px; border-bottom:1px solid #e8eaf0; }.detail-utility form { width:min(560px,42vw); }.detail-utility input { width:100%; min-height:42px; padding:8px 14px; border:1px solid #e5e8ef; border-radius:4px; font:inherit; }.detail-utility > a { color:#26346d; text-decoration:none; font-weight:700; }.detail-utility .detail-new { padding:9px 16px; color:white; background:#4868f7; border-radius:21px; }.immersive-main .entity-detail-header { width:52%; min-height:128px; padding:26px 38px; border-right:1px solid #e8eaf0; }.immersive-main .entity-notice { width:calc(52% - 76px); margin:12px 38px; }.immersive-main .entity-detail-grid { grid-template-columns:52% 48%; }.immersive-main .entity-primary { padding:48px 38px; }.immersive-main .entity-secondary { padding:0 30px; }.immersive-main .recipe-detail-shell .entity-detail-header { width:52%; padding:22px 38px; }.immersive-main .recipe-workspace-tabs { width:48%; margin:-92px 0 30px 52%; min-height:92px; align-items:end; padding:0 22px; }.immersive-main .recipe-overview-strip { width:52%; min-height:150px; padding:20px 38px; border-right:1px solid #e8eaf0; }.immersive-main .recipe-structure-workspace { padding:0 38px; }.immersive-main .recipe-structure-workspace .structure-editor { grid-template-columns:minmax(0,52fr) minmax(420px,48fr); column-gap:0; }.immersive-main .recipe-structure-workspace .section-heading { padding-right:32px; }.immersive-main .recipe-structure-workspace .component-editor,.immersive-main .recipe-structure-workspace > .structure-editor > .secondary-action { margin-right:32px; }.immersive-main .recipe-structure-workspace .method-editor { padding-left:32px; }.immersive-main .recipe-history { margin-inline:38px; }
|
||||
.immersive-main .entity-detail-shell,.immersive-main .recipe-detail-shell { position:relative; }.immersive-main .detail-utility { position:absolute; z-index:10; top:0; right:0; width:48%; height:58px; background:#fff; }.detail-new-menu { position:relative; }.detail-new-menu > summary { padding:9px 16px; color:white; background:#4868f7; border-radius:21px; cursor:pointer; font-weight:700; list-style:none; white-space:nowrap; }.detail-new-menu > summary::-webkit-details-marker { display:none; }.detail-new-menu > div { position:absolute; top:46px; right:0; width:220px; padding:8px 0; background:#fff; border:1px solid #e5e8ef; border-radius:6px; box-shadow:0 12px 30px rgba(22,30,65,.15); }.detail-new-menu > div a { display:flex; gap:12px; padding:13px 18px; color:#111a49; text-decoration:none; }.detail-new-menu > div a:hover { background:#f7f8fc; }.immersive-main .entity-detail-header { min-height:164px; padding-top:24px; }.immersive-main .entity-detail-grid { border-top:1px solid #e8eaf0; }.immersive-main .entity-secondary { margin-top:-106px; background:#fff; }.immersive-main .entity-tabs { min-height:58px; }.immersive-main .recipe-detail-shell .entity-detail-header { min-height:116px; padding-top:20px; }.immersive-main .recipe-workspace-tabs { min-height:58px; margin:-58px 0 28px 52%; }.immersive-main .recipe-workspace-tabs a { padding-block:17px; }.immersive-main .recipe-overview-strip { min-height:126px; }.immersive-main .recipe-structure-workspace { border-top:0; }.immersive-main .recipe-structure-workspace .structure-editor { border-top:1px solid #e8eaf0; }.immersive-main .recipe-structure-workspace .section-heading { min-height:76px; margin:0; padding:18px 32px 12px 0; }.immersive-main .recipe-structure-workspace .section-heading h2 { margin:2px 0; font:700 1.35rem var(--sans); }
|
||||
.edit-command { padding:8px 15px; color:#4868f7; border:1px solid #4868f7; border-radius:20px; text-decoration:none; font-weight:700; }.recipe-view-workspace { display:grid; grid-template-columns:52% 48%; border-top:1px solid #e8eaf0; }.recipe-view-formula { min-width:0; padding:26px 32px 48px 38px; border-right:1px solid #e8eaf0; }.recipe-view-formula .calculator { padding:0; }.recipe-view-formula .calculator-heading { display:flex; align-items:baseline; gap:24px; margin-bottom:22px; }.recipe-view-formula .calculator-heading h2 { margin:0; font:700 1.4rem var(--sans); }.recipe-view-formula .calculator-heading .eyebrow { display:none; }.recipe-view-formula .basis-input { min-width:0; }.recipe-view-formula .formula-component { margin:0 0 14px; border:0; background:transparent; }.recipe-view-formula .formula-component h3 { margin:0; padding:8px 0; background:transparent; border-bottom:1px solid #f3f3f3; font:500 1.125rem var(--sans); }.recipe-view-formula .recipe-ingredients th { color:#8b93a7; background:white; font-size:.72rem; font-weight:500; }.recipe-view-formula .recipe-ingredients td { border:0; }.recipe-view-formula .formula-total { margin-top:12px; }.recipe-view-details { min-width:0; padding:28px 30px 60px; }.recipe-view-method h2,.recipe-derived-panel > h2 { font:700 1.4rem var(--sans); }.recipe-view-method h2 small { color:#9aa2b5; font-size:.85rem; }.recipe-view-method ol { padding:0; list-style:none; }.recipe-derived-panel { margin-top:46px; padding-top:26px; border-top:1px solid #e8eaf0; }.recipe-derived-panel dl { margin:0; }.recipe-derived-panel dl > div { display:flex; justify-content:space-between; padding:12px 4px; border-bottom:1px solid #edf0f5; }.recipe-derived-panel dd { margin:0; font-weight:700; }.recipe-derived-panel .nutrition-wrap { display:block; }.recipe-derived-panel .nutrition-label { max-width:520px; }
|
||||
.ingredient-identity-form { display:grid; grid-template-columns:1fr 180px; gap:12px; margin:0 0 42px; padding:20px; background:#fafbfe; border:1px solid #e8eaf0; }.ingredient-identity-form h2 { grid-column:1/-1; margin:0 0 6px; font:700 1.2rem var(--sans); }.ingredient-identity-form label { display:grid; gap:5px; color:#788197; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; }.ingredient-identity-form label:nth-of-type(3),.ingredient-identity-form label:nth-of-type(4) { grid-column:1/-1; }.ingredient-identity-form input,.ingredient-identity-form select,.ingredient-identity-form textarea { width:100%; padding:9px; color:#111a49; background:#fff; border:1px solid #dfe3ec; font:1rem var(--sans); text-transform:none; letter-spacing:normal; }.ingredient-identity-form button { justify-self:start; padding:9px 13px; color:white; background:#4868f7; border:0; border-radius:5px; font-weight:700; }.primary-command { padding:9px 16px; color:white; background:#43b99f; border-radius:21px; text-decoration:none; font-weight:700; }
|
||||
.ingredient-identity-data { display:none; }
|
||||
.ingredient-prep-section .panel-intro { max-width:650px; line-height:1.55; }
|
||||
.prep-action-table td:first-child small { display:block; margin-top:3px; color:#8d93a7; font-weight:400; }
|
||||
.prep-action-editor .inline-form { display:grid; align-items:end; grid-template-columns:minmax(150px,1.2fr) minmax(110px,.55fr) minmax(160px,1fr) auto; }
|
||||
.prep-action-editor label { display:grid; gap:5px; color:#8b92a6; font-size:11px; }
|
||||
.prep-action-editor label input,.prep-action-editor label select { width:100%; }
|
||||
.percent-input { display:flex; align-items:center; min-width:0; border:1px solid #dfe3ec; background:#fff; }
|
||||
.percent-input input { min-width:0; border:0 !important; }
|
||||
.percent-input i { padding-right:10px; color:#8b92a6; font-style:normal; }
|
||||
.field-help { margin:10px 0 0; color:#9298aa; font-size:12px; line-height:1.5; }
|
||||
.alias-editor { display:grid; gap:7px; margin-top:14px; color:#8b92a6; font-size:11px; }
|
||||
.alias-editor textarea { width:100%; padding:10px 12px; resize:vertical; border:1px solid #dfe3ec; border-radius:3px; color:#111a49; font:14px var(--sans); }
|
||||
@media (max-width:720px) { .prep-action-table { display:block; overflow-x:auto; }.prep-action-table th,.prep-action-table td { min-width:90px; white-space:nowrap; }.prep-action-editor .inline-form { grid-template-columns:1fr; } }
|
||||
.ingredient-title-editor { overflow:hidden; resize:none; field-sizing:content; }
|
||||
.ingredient-prep-section .inline-editor > summary { display:inline-flex; align-items:center; min-height:40px; margin-top:5px; padding:8px 16px; color:#202962; background:#fff; border:1px solid #202962; border-radius:22px; font-size:14px; font-weight:600; }
|
||||
.ingredient-additional-edit { padding:42px 32px 62px !important; border:1px solid #f3f3f3 !important; border-radius:4px; }
|
||||
.ingredient-detail-fields { display:grid; gap:24px; margin-top:28px; }
|
||||
.ingredient-detail-fields > label { display:grid; gap:8px; color:#3c4679; font-size:14px; }
|
||||
.ingredient-detail-fields > label > small { margin-top:-5px; color:#a5a9c1; font-size:11px; }
|
||||
.ingredient-detail-fields input,.ingredient-detail-fields textarea { width:100%; min-height:44px; padding:10px 12px; color:#050841; background:#fff; border:1px solid #ececec; border-bottom-color:#b5b5b5; border-radius:0; font:15px var(--sans); resize:vertical; }
|
||||
.ingredient-detail-fields textarea:focus,.ingredient-detail-fields input:focus { background:#f1f5fe; border-bottom-color:#3d5df6; outline:0; }
|
||||
.ingredient-tag-view,.ingredient-description-view { padding:16px 0; border-bottom:1px solid #eeeef3; }
|
||||
.ingredient-tag-view > strong,.ingredient-description-view > strong { display:block; margin-bottom:10px; color:#3c4679; font-size:13px; }
|
||||
.ingredient-tag-view > span { display:flex; flex-wrap:wrap; gap:7px; }
|
||||
.ingredient-tag-view i { padding:5px 11px; color:#202962; background:#f1f5fe; border-radius:16px; font-size:12px; font-style:normal; }
|
||||
.ingredient-description-view p { margin:0; color:#505773; line-height:1.55; }
|
||||
.ingredient-cost-edit { display:grid; gap:28px; }
|
||||
.ingredient-purchase-form { padding-bottom:28px; border-bottom:1px solid #f3f3f3; }
|
||||
.ingredient-purchase-form > header { display:flex; align-items:center; justify-content:space-between; gap:15px; margin-bottom:18px; }
|
||||
.ingredient-purchase-form h3 { margin:0; color:#3c4679; font-size:14px; font-weight:600; }
|
||||
.ingredient-purchase-form > header form { margin:0; }
|
||||
.ingredient-purchase-form > header button { padding:5px 7px; color:#f63d48; background:transparent; border:0; cursor:pointer; font-size:11px; }
|
||||
.ingredient-purchase-form .purchase-item-form { margin:0; padding:0; background:#fff; border:0; border-radius:0; }
|
||||
.ingredient-purchase-form .purchase-item-form label { color:#3c4679; font-size:11px; font-weight:500; letter-spacing:0; text-transform:none; }
|
||||
.ingredient-purchase-form .purchase-item-form input,.ingredient-purchase-form .purchase-item-form select { min-height:44px; padding:9px 10px; background:#ffffff; border-color:#ececec; border-radius:0; }
|
||||
.ingredient-purchase-form .purchase-form-actions button { padding:9px 16px; background:#3d5df6; border-color:#3d5df6; border-radius:20px; }
|
||||
.ingredient-cost-row { display:flex; align-items:center; justify-content:space-between; gap:20px; min-height:68px; padding:12px 0; border-bottom:1px solid #edf0f5; }
|
||||
.ingredient-cost-row > span:last-child { text-align:right; }
|
||||
.ingredient-cost-row strong,.ingredient-cost-row small { display:block; }
|
||||
.ingredient-cost-row small { margin-top:3px; color:#a5a9c1; font-size:11px; }
|
||||
.detail-actions-menu label { display:grid; gap:6px; padding:9px 14px; color:#a5a9c1; font-size:11px; }
|
||||
.detail-actions-menu select { width:250px; padding:9px; }
|
||||
.application-body .ingredient-actions-menu > div { width:290px; }
|
||||
@media (min-width:981px) { .application-body .entity-detail-shell .entity-primary { background:#ffffff; } }
|
||||
.application-body .entity-detail-shell .entity-detail-header { background:#ffffff; }
|
||||
.application-body .entity-detail-shell .entity-primary { padding-top:35px; }
|
||||
.application-body .entity-detail-shell .entity-section { margin-bottom:35px; }
|
||||
.application-body .entity-detail-shell .entity-section h2,.application-body .entity-detail-shell .entity-tab-panel h2 { color:#050841; font-size:22px; font-weight:700; line-height:1.25; }
|
||||
.application-body .entity-detail-shell .panel-intro { max-width:640px; margin:5px 0 20px; color:#95969c; font-size:14px; line-height:1.5; }
|
||||
.application-body .entity-detail-shell .entity-tabs { position:relative; min-height:58px; background:#fff; border-color:#edf0f5; }
|
||||
.application-body .entity-detail-shell .entity-tabs button { min-height:58px; padding:8px 12px; color:#050841; font-size:15px; font-weight:400; }
|
||||
.application-body .entity-detail-shell .entity-tabs button.active { color:#050841; background:#f1f5fe; border-bottom:2px solid #3d5df6; }
|
||||
.application-body .entity-detail-shell .entity-tab-panel { padding:35px 2px 60px; }
|
||||
.application-body .entity-detail-shell .prep-action-table { width:100%; margin-top:26px; table-layout:fixed; }
|
||||
.application-body .entity-detail-shell .prep-action-table th { height:40px; padding:8px 10px; color:#757677; background:#ffffff; border-color:#edf0f5; font-size:12px; }
|
||||
.application-body .entity-detail-shell .prep-action-table td { height:58px; padding:10px; color:#050841; background:#fff; border:1px solid #edf0f5; font-size:14px; }
|
||||
.application-body .entity-detail-shell .inline-editor > summary,.application-body .entity-detail-shell .add-purchase > summary { display:inline-flex; align-items:center; min-height:40px; padding:8px 16px; color:#202962; background:#fff; border:1px solid #202962; border-radius:22px; font-size:14px; font-weight:600; list-style:none; }
|
||||
.application-body .entity-detail-shell .inline-editor > summary::-webkit-details-marker,.application-body .entity-detail-shell .add-purchase > summary::-webkit-details-marker { display:none; }
|
||||
.application-body .entity-detail-shell .inline-form { gap:10px; margin-top:18px; padding-top:18px; border-color:#f3f3f3; }
|
||||
.application-body .entity-detail-shell .inline-form label { color:#3c4679; font-size:11px; font-weight:500; }
|
||||
.application-body .entity-detail-shell .inline-form input,.application-body .entity-detail-shell .inline-form select { min-height:44px; padding:9px 10px; color:#050841; background:#fff; border:1px solid #ececec; border-radius:0; font-size:14px; }
|
||||
.application-body .entity-detail-shell .inline-form input:focus,.application-body .entity-detail-shell .inline-form select:focus { background:#f1f5fe; border-color:#3d5df6; outline:0; }
|
||||
.application-body .entity-detail-shell .inline-form button,.application-body .entity-detail-shell .usda-id-form button { min-height:38px; padding:8px 16px; color:#fff; background:#3d5df6; border:0; border-radius:20px; font-size:13px; font-weight:600; }
|
||||
.application-body .equivalency-editor .inline-form { display:grid; grid-template-columns:90px minmax(120px,1fr) auto 90px minmax(120px,1fr) minmax(130px,1fr) auto; align-items:end; }
|
||||
.application-body .equivalency-editor .inline-form label { display:grid; gap:5px; }
|
||||
.application-body .equivalency-editor .inline-form > i { align-self:center; color:#a5a9c1; font-style:normal; }
|
||||
.application-body .equivalency-list { margin-top:8px; }
|
||||
.application-body .equivalency-list p { min-height:58px; margin:0; padding:12px 8px; border-color:#f3f3f3; color:#050841; }
|
||||
.application-body .equivalency-list p small { color:#a5a9c1; font-size:11px; }
|
||||
.application-body .equivalency-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; border-bottom:1px solid #f3f3f3; }
|
||||
.application-body .equivalency-row > p { border-bottom:0; }
|
||||
.application-body .equivalency-actions { display:flex; gap:4px; }
|
||||
.application-body .equivalency-actions form { margin:0; }
|
||||
.application-body .equivalency-actions button,.application-body .cancel-equivalency { padding:6px 8px; color:#3d5df6; background:transparent; border:0; cursor:pointer; font-size:11px; }
|
||||
.application-body .equivalency-actions form button { color:#f63d48; }
|
||||
.application-body .cancel-equivalency { align-self:end; min-height:38px; }
|
||||
.application-body .usda-id-form { gap:10px; margin:24px 0; padding:18px 0; border-block:1px solid #f3f3f3; }
|
||||
.application-body .usda-id-form label { gap:6px; color:#3c4679; font-size:11px; font-weight:500; }
|
||||
.application-body .usda-id-form input { min-height:44px; padding:9px 10px; background:#fff; border:1px solid #ececec; border-radius:0; font-size:14px; }
|
||||
.application-body .usda-id-form a,.application-body .nutrition-mapping-head a { color:#3d5df6; font-size:12px; font-weight:600; text-decoration:none; }
|
||||
.application-body .ingredient-nutrition { margin-top:20px; padding:0; background:#fff; border:0; border-radius:0; }
|
||||
.application-body .nutrition-mapping-head { padding:0 0 14px; border-bottom:1px solid #3d5df6; }
|
||||
.application-body .ingredient-nutrition dl div { min-height:44px; padding:10px 6px; border-color:#f3f3f3; font-size:13px; }
|
||||
.application-body .ingredient-nutrition footer { color:#a5a9c1; }
|
||||
.application-body .prep-edit-wrap { overflow-x:auto; margin-top:26px; }
|
||||
.application-body .prep-edit-table { min-width:700px; margin-top:0 !important; }
|
||||
.application-body .prep-edit-table th:first-child { width:31%; }
|
||||
.application-body .prep-edit-table th:nth-child(2) { width:15%; }
|
||||
.application-body .prep-edit-table th:last-child { width:44px; }
|
||||
.application-body .prep-edit-table select,.application-body .prep-edit-table input { width:100%; min-height:36px; padding:7px 8px; color:#050841; background:#fff; border:0; border-bottom:1px solid #ececec; border-radius:0; font-size:13px; }
|
||||
.application-body .prep-edit-table .prep-row-notes { margin-top:3px; color:#757677; font-size:11px; font-style:italic; }
|
||||
.application-body .prep-edit-table .percent-input { border:0; }
|
||||
.application-body .prep-edit-table .percent-input i { padding-right:5px; }
|
||||
.application-body .prep-row-remove { display:grid; place-items:center; width:30px; height:30px; padding:0; color:#a5a9c1; background:transparent; border:0; border-radius:50%; cursor:pointer; font-size:20px; }
|
||||
.application-body .prep-row-remove:hover { color:#f63d48; background:#fff1f2; }
|
||||
.application-body .prep-equivalency-link { width:100%; min-height:32px; padding:4px; color:#3d5df6; background:transparent; border:0; border-radius:3px; cursor:pointer; font-size:12px; text-align:left; }
|
||||
.application-body .prep-equivalency-link:hover { background:#f1f5fe; text-decoration:underline; }
|
||||
.application-body .outlined-add-action { min-height:40px; margin-top:20px; padding:8px 16px; color:#202962; background:#fff; border:1px solid #202962; border-radius:22px; cursor:pointer; font-size:14px; font-weight:600; }
|
||||
.application-body #ingredient-done.dirty::after { content:""; display:inline-block; width:6px; height:6px; margin-left:7px; background:#fff; border-radius:50%; vertical-align:middle; }
|
||||
.recipe-tab-panel { display:none; }.recipe-tab-panel.active { display:block; }.recipe-view-details > .recipe-derived-panel { margin-top:0; padding-top:0; border-top:0; }.recipe-view-details .derived-grid { display:block; }.recipe-view-details .derived-card { border:0; padding:0; }.recipe-view-details .derived-title h2 { font:700 1.4rem var(--sans); }
|
||||
.recipe-view-equivalencies h2,.recipe-equivalence-editor h2 { font:700 1.4rem var(--sans); }.recipe-view-equivalencies > div,.recipe-equivalence-editor > div { display:grid; grid-template-columns:1fr auto 1fr; gap:12px; padding:14px 4px; border-bottom:1px solid #e8eaf0; }.recipe-view-equivalencies small,.recipe-equivalence-editor small { grid-column:1/-1; color:#8b93a7; }.recipe-equivalence-editor { margin:30px 38px; padding:24px; border:1px solid #e8eaf0; }.recipe-equivalence-editor > p { color:#8b93a7; }.recipe-equivalence-editor form { display:grid; grid-template-columns:90px 1fr auto 90px 1fr minmax(150px,1fr) auto; align-items:center; gap:8px; margin-top:20px; }.recipe-equivalence-editor input,.recipe-equivalence-editor select { min-width:0; padding:9px; border:1px solid #dfe3ec; }.recipe-equivalence-editor button { padding:10px 12px; color:white; background:#4868f7; border:0; border-radius:5px; font-weight:700; }
|
||||
.editable-entity-title {
|
||||
display:block;
|
||||
width:min(680px,50vw);
|
||||
margin:0;
|
||||
padding:2px 0 4px;
|
||||
color:#050841;
|
||||
background:transparent;
|
||||
border:0;
|
||||
border-bottom:1px solid transparent;
|
||||
border-radius:0;
|
||||
font-family:var(--fmt-font-sans);
|
||||
font-size:28px;
|
||||
font-weight:900;
|
||||
line-height:42px;
|
||||
letter-spacing:0;
|
||||
}
|
||||
.editable-entity-title:hover,
|
||||
.editable-entity-title:focus {
|
||||
border-bottom-color:#3d5df6;
|
||||
outline:0;
|
||||
background:transparent;
|
||||
}
|
||||
#recipe-done {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
min-height:32px;
|
||||
padding:6px 18px;
|
||||
color:#fff;
|
||||
background:#3d5df6;
|
||||
border:0;
|
||||
border-radius:100px;
|
||||
font-family:var(--fmt-font-sans);
|
||||
font-size:15px;
|
||||
font-weight:500;
|
||||
cursor:pointer;
|
||||
box-shadow:none;
|
||||
}
|
||||
#recipe-done:hover {
|
||||
background:#2f4de0;
|
||||
}
|
||||
.calculate-toggle { display:inline-flex; align-items:center; gap:9px; color:#26346d; font-size:.82rem; font-weight:700; white-space:nowrap; }.calculate-toggle input { position:absolute; opacity:0; pointer-events:none; }.calculate-toggle i { position:relative; width:35px; height:20px; background:#cbd1dc; border-radius:12px; }.calculate-toggle i::after { content:""; position:absolute; top:3px; left:3px; width:14px; height:14px; background:#fff; border-radius:50%; transition:.15s; }.calculate-toggle input:checked + i { background:#43b99f; }.calculate-toggle input:checked + i::after { transform:translateX(15px); }.calculator-heading .calculate-toggle { grid-column:1/-1; justify-self:end; }.structure-heading-actions { display:flex; align-items:center; gap:18px; }.recipe-edit-workspace { display:grid; grid-template-columns:52% 48%; border-top:1px solid #e8eaf0; }.recipe-edit-structure { min-width:0; padding:0 32px 48px 38px; border-right:1px solid #e8eaf0; }.recipe-edit-structure .structure-editor { display:block; }.recipe-edit-structure .section-heading { min-height:76px; margin:0; padding:18px 0 12px; }.recipe-edit-structure .component-editor { margin-right:0; background:#fff; border-color:#e8eaf0; }.recipe-edit-static-method { min-width:0; padding:28px 30px 60px; }.recipe-edit-structure .editor-items { min-width:54rem; }.recipe-edit-structure .editor-items th:nth-child(2),.recipe-edit-structure .editor-items td:nth-child(2) { width:6rem; }.recipe-edit-structure .editor-items th:nth-child(3),.recipe-edit-structure .editor-items td:nth-child(3) { width:7rem; text-align:right; }.recipe-edit-structure .editor-items th:nth-child(4),.recipe-edit-structure .editor-items td:nth-child(4) { width:11rem; }.recipe-edit-structure .editor-items th:nth-child(5),.recipe-edit-structure .editor-items td:nth-child(5) { width:9rem; }.recipe-edit-structure .editor-items th:nth-child(6),.recipe-edit-structure .editor-items td:nth-child(6) { width:6rem; }.recipe-edit-structure .editor-items th:last-child,.recipe-edit-structure .editor-items td:last-child { width:8rem; }.recipe-edit-static-method h2 { margin-bottom:18px; }
|
||||
.percent-mode { display:inline-flex; border:1px solid #dfe3ec; border-radius:18px; overflow:hidden; }.percent-mode button { min-width:96px; padding:7px 11px; color:#26346d; background:#fff; border:0; cursor:pointer; font-weight:700; }.percent-mode button.active { background:#e9efff; }.calculator-percent-controls { grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; gap:14px; }.calculator-percent-controls .calculate-toggle { justify-self:auto; }.basis-choice { display:inline-flex; align-items:center; gap:4px; margin-left:10px; color:#4868f7; font-size:.68rem; font-weight:700; }.basis-choice input { width:auto; }.structure-heading-actions { flex-wrap:wrap; justify-content:flex-end; }
|
||||
.editor-items .base-column { width:4.5rem; text-align:center; }.editor-items .base-column input { width:17px; height:17px; margin:0; accent-color:#4868f7; }.bakers-base-summary { display:grid; grid-template-columns:55px 1fr; gap:14px; margin:0 0 28px; padding:18px; background:#f7f7f8; border-radius:5px; }.bakers-base-summary > strong { color:#a0a7ba; font-weight:500; }.bakers-base-summary p { display:grid; grid-template-columns:80px 1fr; gap:10px; margin:0 0 10px; }.bakers-base-summary p:last-child { margin-bottom:0; }.bakers-base-summary a { color:#111a49; text-decoration:none; font-weight:700; }.bakers-base-summary span { color:#26346d; }
|
||||
.bakers-base-summary p > span { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; border-bottom:1px solid #a5a9c1; }
|
||||
.bakers-base-summary input,.bakers-base-summary select { min-width:0; padding:3px 2px; color:#26346d; background:transparent; border:0; font:inherit; }
|
||||
.bakers-base-summary input { width:100%; text-align:right; }
|
||||
.bakers-base-summary select { max-width:48px; color:#3d5df6; }
|
||||
.entity-directory-table { background:#fff; border:1px solid #eef0f5; }.entity-directory-toolbar { display:grid; grid-template-columns:36px 64px minmax(12rem,1fr) auto; align-items:center; gap:8px; min-height:58px; padding:0 20px; border-bottom:1px solid #eef0f5; }.entity-directory-toolbar input,.entity-directory-row > input { width:20px; height:20px; margin:0; accent-color:#3d5df6; }.entity-directory-toolbar strong { color:#5366fa; font-size:.88rem; }.entity-directory-toolbar button { padding:7px 10px; color:#64748b; background:transparent; border:0; cursor:pointer; }.entity-directory-toolbar .bulk-delete { color:#a73838; }.directory-error { margin:0; padding:11px 20px; color:#8d2e2e; background:#fff1f1; border-bottom:1px solid #f5cece; }.entity-directory-head,.entity-directory-row { display:grid; align-items:center; gap:8px; padding:0 20px; }.entity-directory-head { grid-template-columns:36px 64px minmax(14rem,1fr) repeat(var(--directory-columns),minmax(7rem,10rem)) 28px; min-height:38px; color:#8b93a7; border-bottom:1px solid #eef0f5; font-size:.7rem; text-transform:uppercase; }.entity-directory-head > span:first-child { grid-column:auto; }.entity-directory-row { grid-template-columns:36px 64px minmax(14rem,1fr) repeat(var(--directory-columns),minmax(7rem,10rem)) 28px; min-height:62px; border-bottom:1px solid #eef0f5; }.entity-directory-row:last-child { border-bottom:0; }.entity-directory-row.selected { background:#edf2ff; }.entity-directory-head.columns-1,.entity-directory-row.columns-1 { --directory-columns:1; }.entity-directory-head.columns-2,.entity-directory-row.columns-2 { --directory-columns:2; }.entity-directory-head.columns-3,.entity-directory-row.columns-3 { --directory-columns:3; }.entity-directory-name { display:flex; flex-direction:column; min-width:0; }.entity-directory-name a { overflow:hidden; color:#111a49; text-decoration:none; text-overflow:ellipsis; white-space:nowrap; }.entity-directory-name small { color:#8b93a7; }.directory-attention { display:flex; gap:5px; }.directory-attention em { padding:2px 5px; color:#9a3412; background:#fff0e8; border-radius:3px; font-size:.62rem; font-style:normal; font-weight:700; text-transform:uppercase; }.entity-directory-value { overflow:hidden; color:#687086; font-size:.82rem; text-overflow:ellipsis; white-space:nowrap; }.entity-row-actions { position:relative; }.entity-row-actions > summary { color:#a0a7ba; cursor:pointer; font-size:1.4rem; list-style:none; text-align:center; }.entity-row-actions > summary::-webkit-details-marker { display:none; }.entity-row-actions > div { position:absolute; z-index:5; top:28px; right:0; width:130px; padding:5px; background:#fff; border:1px solid #dfe3ec; border-radius:5px; box-shadow:0 8px 22px rgba(20,30,60,.14); }.entity-row-actions a,.entity-row-actions button { display:block; width:100%; padding:8px 10px; color:#26346d; background:transparent; border:0; text-align:left; text-decoration:none; cursor:pointer; }.entity-row-actions button { color:#a73838; }.entity-row-actions a:hover,.entity-row-actions button:hover { background:#f7f8fc; }
|
||||
.delete-confirmation { width:min(700px,calc(100% - 32px)); padding:30px 38px; color:#050841; background:#fff; border:0; border-radius:10px; box-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); }.delete-confirmation::backdrop { background:rgba(0,0,0,0.5); }.delete-confirmation h2 { margin:0 30px 12px 0; color:#202962; font:700 28px/39px var(--sans); }.delete-confirmation p { margin:0 0 30px; color:#050841; font-size:15px; font-weight:400; line-height:1.5; }.delete-confirmation > div { display:flex; justify-content:flex-end; align-items:center; gap:52px; }.delete-confirmation button { padding:0; background:transparent; border:0; cursor:pointer; color:#050841; font-size:15px; font-weight:500; }.delete-confirmation .confirm-delete { min-width:250px; padding:16px 24px; color:#fff; background:#f63d48; border-radius:100px; font-size:16px; font-weight:500; line-height:16px; }.delete-confirmation .dialog-close { position:absolute; top:18px; right:18px; padding:4px; color:#a5a9c1; border:0; font-size:24px; }
|
||||
.entity-directory-head,.entity-directory-row,.entity-directory-head.columns-1,.entity-directory-head.columns-2,.entity-directory-head.columns-3,.entity-directory-row.columns-1,.entity-directory-row.columns-2,.entity-directory-row.columns-3 { grid-template-columns:36px 64px minmax(14rem,1fr) 28px; }.entity-directory-head > span:first-child { grid-column:auto; }.entity-directory-head > span:nth-child(2) { text-align:left; }.entity-directory-name { justify-content:center; }.entity-directory-toolbar { min-height:52px; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Light.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Book.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Medium.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Black.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--ink: #050841;
|
||||
--ink-dark: #050841;
|
||||
--ink-medium: #202962;
|
||||
--ink-secondary: #3C4679;
|
||||
--muted: #8283a0;
|
||||
--muted-light: #a5a9c1;
|
||||
--paper: #ffffff;
|
||||
--card: #ffffff;
|
||||
--card-alt: #fafbfd;
|
||||
--card-paper: #f1f5fe;
|
||||
--line: #edf0f5;
|
||||
--line-subtle: #f4f5f8;
|
||||
--line-focus: #3d5df6;
|
||||
--blue: #3d5df6;
|
||||
--blue-hover: #1236e1;
|
||||
--blue-active: #001992;
|
||||
--blue-light: #647df8;
|
||||
--blue-tint: #f1f5fe;
|
||||
--blue-badge: #dbe4ff;
|
||||
--green: #40b49a;
|
||||
--green-dark: #20ab85;
|
||||
--green-tint: #abddd1;
|
||||
--orange: #f3a642;
|
||||
--red: #f63d48;
|
||||
--red-dark: #bc0020;
|
||||
--red-tint: #fff5f6;
|
||||
--purple: #6234f2;
|
||||
--pink-badge: #ffd2f5;
|
||||
--serif: "CircularCustCapNum", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--sans: "CircularCustCapNum", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
html {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
body, button, input, select, textarea {
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
body { margin:0; min-height:100vh; }
|
||||
a { color:inherit; text-decoration-thickness:1px; text-underline-offset:.2em; }
|
||||
.shell { width:min(1160px,calc(100% - 40px)); margin-inline:auto; }
|
||||
.site-header { min-height:76px; padding:0 max(20px,calc((100vw - 1160px)/2)); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:rgba(244,240,231,.94); position:sticky; top:0; z-index:10; backdrop-filter:blur(12px); }
|
||||
.brand { display:flex; gap:12px; align-items:center; font-family:var(--serif); font-size:1.15rem; font-weight:600; text-decoration:none; }
|
||||
.brand img { width:31px; height:31px; }
|
||||
nav { display:flex; gap:24px; }
|
||||
nav a { text-decoration:none; color:var(--muted); font-size:.9rem; font-weight:600; }
|
||||
nav a:hover { color:var(--ink); }
|
||||
main { min-height:calc(100vh - 150px); }
|
||||
h1,h2,h3 { font-family:var(--serif); line-height:1.08; margin-top:0; }
|
||||
h1 { font-size:clamp(3rem,7vw,6.4rem); letter-spacing:-.045em; }
|
||||
h2 { font-size:clamp(2rem,4vw,3.4rem); letter-spacing:-.03em; }
|
||||
h3 { font-size:1.35rem; }
|
||||
p { line-height:1.65; }
|
||||
.eyebrow { text-transform:uppercase; letter-spacing:.16em; font-size:.72rem; font-weight:600; color:var(--orange); margin:0 0 14px; }
|
||||
.button { display:inline-flex; padding:13px 20px; border:1px solid var(--ink); text-decoration:none; font-weight:600; font-size:.9rem; }
|
||||
.button.primary { color:var(--paper); background:var(--green); border-color:var(--green); }
|
||||
.section-block { padding-block:80px; }
|
||||
.section-heading { display:flex; justify-content:space-between; align-items:end; margin-bottom:32px; }
|
||||
.section-heading h2 { margin-bottom:0; }
|
||||
.recipe-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
|
||||
.recipe-card { min-height:245px; padding:25px; border:1px solid var(--line); background:var(--card); display:flex; flex-direction:column; }
|
||||
.recipe-card h2 { font-size:1.65rem; margin:35px 0 10px; }
|
||||
.recipe-card h2 a { text-decoration:none; }
|
||||
.recipe-card p { color:var(--muted); font-size:.92rem; margin-top:0; }
|
||||
.card-topline { display:flex; justify-content:space-between; text-transform:uppercase; font-size:.68rem; letter-spacing:.1em; color:var(--muted); }
|
||||
.tag-row { display:flex; flex-wrap:wrap; gap:8px 14px; margin-top:auto; }
|
||||
.tag-row a { color:var(--green); font-size:.78rem; text-decoration:none; }
|
||||
.page-heading { padding-block:85px 50px; border-bottom:1px solid var(--line); }.page-heading h1 { margin-bottom:15px; }.page-heading p:last-child { color:var(--muted); font-size:1.05rem; }
|
||||
.taxonomy-grid { padding-block:50px 90px; display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }.taxonomy-grid a { display:flex; flex-direction:column; gap:8px; padding:25px; border:1px solid var(--line); background:var(--card); text-decoration:none; }.taxonomy-grid span { color:var(--muted); font-size:.85rem; }
|
||||
.tag-cloud { padding-block:50px 90px; display:flex; gap:12px; flex-wrap:wrap; }.tag-cloud a { padding:10px 14px; border:1px solid var(--line); background:var(--card); text-decoration:none; }.tag-cloud small { color:var(--muted); margin-left:4px; }
|
||||
.recipe-page { padding-block:70px 100px; }.recipe-header { display:grid; grid-template-columns:1fr 280px; gap:80px; align-items:end; padding-bottom:55px; border-bottom:1px solid var(--line); }.recipe-header h1 { font-size:clamp(3rem,6vw,5.6rem); margin:18px 0; }.recipe-kicker { display:flex; gap:20px; text-transform:uppercase; color:var(--muted); letter-spacing:.12em; font-size:.7rem; }.recipe-facts { margin:0; display:grid; grid-template-columns:1fr 1fr; border-top:1px solid var(--ink); }.recipe-facts div { padding:17px 5px; border-bottom:1px solid var(--line); }.recipe-facts dt { color:var(--muted); font-size:.7rem; text-transform:uppercase; letter-spacing:.1em; }.recipe-facts dd { margin:5px 0 0; font-weight:600; }
|
||||
.notice { margin-top:28px; padding:16px 20px; border-left:4px solid var(--orange); background:#f3dfd4; position:relative; }
|
||||
.success-notice { margin-bottom:1rem; padding:1rem; color:#204f31; background:#dcebdc; border-left:4px solid #39734c; position:relative; }
|
||||
.notice,.success-notice { display:flex; align-items:center; justify-content:space-between; gap:12px; border-radius:6px; }
|
||||
.notice-dismiss-btn { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; padding:0; background:transparent; border:0; color:inherit; font-size:18px; line-height:1; cursor:pointer; opacity:.7; border-radius:4px; transition:opacity .12s ease, background-color .12s ease; }
|
||||
.notice-dismiss-btn:hover { opacity:1; background:rgba(0,0,0,.06); }
|
||||
.calculator { margin-top:65px; }.calculator-heading { display:flex; justify-content:space-between; align-items:end; margin-bottom:25px; }.calculator-heading h2 { margin:0; }.basis-input { display:flex; flex-direction:column; gap:7px; font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }.input-with-unit { display:flex; align-items:center; border-bottom:1px solid var(--ink); color:var(--ink); }.input-with-unit input { width:120px; padding:8px 5px; border:0; background:transparent; font:600 1.1rem var(--sans); color:var(--ink); outline:none; }
|
||||
.quantity-control { display:inline-flex; align-items:center; border-bottom:1px solid var(--ink); }.quantity-control input { width:9rem; padding:.55rem .3rem; border:0; background:transparent; color:var(--ink); font:600 1.05rem var(--sans); }.quantity-control select { padding:.55rem .3rem; border:0; background:transparent; color:var(--green); font-weight:700; cursor:pointer; }.line-quantity { display:grid; grid-template-columns:7rem 6rem; justify-content:end; border-bottom:0; }.line-quantity input { width:100%; text-align:right; }.line-quantity select { width:100%; text-align:left; }
|
||||
.formula-component { margin-top:30px; }.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; } th,td { padding:14px 10px; border-bottom:1px solid var(--line); text-align:left; } th:not(:first-child),td:not(:first-child) { text-align:right; } th { color:var(--muted); font-size:.68rem; text-transform:uppercase; letter-spacing:.1em; }.recipe-ingredients { table-layout:fixed; }.recipe-ingredients th:first-child,.recipe-ingredients td:first-child { text-align:left; }.recipe-ingredients th:nth-last-child(2):not(:first-child),.recipe-ingredients td:nth-last-child(2):not(:first-child) { width:8rem; text-align:right; }.recipe-ingredients th:last-child,.recipe-ingredients td:last-child { width:14rem; text-align:right; }.basis-row td:first-child { color:var(--green); font-weight:600; }td small { display:block; color:var(--muted); margin-top:3px; }.muted { color:var(--muted); font-size:.75em; }.formula-total { display:flex; justify-content:flex-end; gap:60px; padding:20px 10px; background:var(--green); color:white; }
|
||||
.derived-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr)); gap:1rem; margin-top:2rem; }.derived-card { padding:1.15rem; background:var(--card); border:1px solid var(--line); }.derived-title { display:flex; justify-content:space-between; gap:1rem; align-items:end; padding-bottom:.6rem; border-bottom:3px solid var(--green); }.derived-title h3,.derived-title p { margin:0; }.derived-title > strong { font-size:.75rem; }.derived-card dl { margin:0; }.derived-card dl div { display:flex; justify-content:space-between; gap:1rem; padding:.4rem 0; border-bottom:1px solid var(--line); }.derived-card dt,.derived-card dd { margin:0; }.derived-card dd { font-weight:700; }.derived-batch { display:flex; justify-content:space-between; gap:1rem; }.derived-warning { color:#8b321f; font-size:.8rem; font-weight:700; }
|
||||
.nutrition-wrap { margin:0; }.nutrition-label { padding:.45rem .55rem .55rem; color:#111; background:#fff; border:1px solid #111; font-family:Arial,Helvetica,sans-serif; line-height:1.05; }.nutrition-title { display:block; border-bottom:.65rem solid #111; padding-bottom:.3rem; }.nutrition-title h2 { margin:0; font-family:Arial Black,Arial,Helvetica,sans-serif; font-size:2.65rem; line-height:.95; letter-spacing:-.12rem; }.nutrition-title p { margin:.18rem 0; font-size:.9rem; }.serving-size { display:flex; justify-content:space-between; gap:1rem; padding-top:.25rem; border-top:1px solid #111; font-size:1rem; }.amount-heading,.dv-heading { margin:0; font-size:.72rem; font-weight:700; }.calorie-row { display:flex; justify-content:space-between; align-items:end; border-bottom:.35rem solid #111; font-size:2rem; line-height:1; padding:.15rem 0; }.calorie-row strong:first-child { font-size:1.25rem; }.dv-heading { padding:.2rem 0; text-align:right; border-bottom:1px solid #111; }.nutrition-facts { margin:0; }.nutrition-facts div { display:flex; justify-content:space-between; gap:1rem; padding:.22rem .05rem; border-bottom:1px solid #777; font-size:.9rem; }.nutrition-facts dt,.nutrition-facts dd { margin:0; }.nutrition-facts dd { font-weight:700; }.nutrition-facts .protein { border-bottom:.35rem solid #111; }.dv-footnote { margin:.35rem 0 0; padding-left:.65rem; text-indent:-.55rem; font-size:.68rem; line-height:1.2; }.nutrition-source { margin:.75rem 0 0; font-size:.78rem; color:var(--muted); }
|
||||
.preparation,.notes { width:min(780px,100%); margin-top:80px; }.preparation ol { list-style:none; counter-reset:steps; padding:0; }.preparation li { counter-increment:steps; position:relative; padding:22px 0 22px 62px; border-top:1px solid var(--line); line-height:1.65; }.preparation li::before { content:counter(steps,decimal-leading-zero); position:absolute; left:5px; top:24px; color:var(--orange); font-size:.76rem; font-weight:600; }.preparation li.placeholder { color:var(--orange); }.notes ul { padding-left:20px; }.notes li { margin:10px 0; line-height:1.6; }
|
||||
.equipment-list { width:min(780px,100%); margin-top:60px; }.equipment-list h2 { margin-top:0; }.equipment-list ul { display:flex; flex-wrap:wrap; gap:.6rem; padding:0; list-style:none; }.equipment-list li { padding:.7rem .9rem; background:var(--card); border:1px solid var(--line); }.equipment-list small { display:block; max-width:18rem; margin-top:.2rem; color:var(--muted); }.step-equipment { display:block; margin-top:.35rem; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.06em; }
|
||||
.additional-details { width:min(780px,100%); margin-top:80px; }.additional-details h2 { margin-top:0; }.additional-details dl { margin:0; border-top:1px solid var(--ink); }.additional-details dl > div { display:grid; grid-template-columns:10rem 1fr; gap:1rem; padding:1rem .3rem; border-bottom:1px solid var(--line); }.additional-details dt { color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; }.additional-details dd { margin:0; font-weight:600; }.additional-details dd small { display:block; margin-top:.25rem; color:var(--muted); font-weight:400; }.additional-details ul { margin:0; padding:0; list-style:none; }.additional-details li + li { margin-top:.4rem; }
|
||||
.prose { max-width:850px; padding-block:80px 110px; }.prose h1 { font-size:clamp(3rem,6vw,5rem); }.prose h2 { font-size:2rem; margin-top:50px; }.prose p { color:var(--muted); }
|
||||
.review-toolbar { position:sticky; top:0; z-index:3; display:flex; gap:1rem; align-items:center; flex-wrap:wrap; padding:1rem; margin-bottom:2rem; background:var(--paper); border:1px solid var(--line); box-shadow:0 .5rem 1rem rgba(30,35,25,.08); }.review-toolbar div { margin-right:auto; }.review-toolbar small { display:block; color:var(--muted); }.review-toolbar input[type="search"],.review-toolbar button,.basis-input select { padding:.65rem .8rem; border:1px solid var(--line); background:var(--card); color:var(--ink); }.review-toolbar button { cursor:pointer; background:var(--green); color:white; font-weight:700; }.candidate-card { margin:0 0 1.5rem; padding:1.2rem; border:1px solid var(--line); background:var(--card); }.candidate-card legend { padding:0 .5rem; font:600 1.4rem var(--serif); }.candidate-card code { color:var(--muted); font:400 .72rem var(--sans); }.candidate-choice { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:1rem; padding:.8rem; border-bottom:1px solid var(--line); cursor:pointer; }.candidate-choice:has(input:checked) { background:#dce6d9; outline:2px solid var(--green); }.candidate-choice small { display:block; margin-top:.2rem; color:var(--muted); }.candidate-choice a { font-size:.78rem; }.candidate-choice.none { border-bottom:0; }
|
||||
.product-meta { color:var(--muted); font-size:.82rem; text-transform:capitalize; }
|
||||
.other-choice { display:flex; gap:1rem; align-items:center; justify-content:space-between; padding:.8rem; border-bottom:1px solid var(--line); }.other-choice select { min-width:min(25rem,55vw); padding:.6rem; border:1px solid var(--line); background:var(--paper); }
|
||||
.quality-summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(9rem,1fr)); gap:1rem; padding-bottom:1rem; }.quality-summary article { display:flex; flex-direction:column; padding:1.1rem; background:var(--card); border:1px solid var(--line); }.quality-summary strong { font:600 2.25rem var(--serif); color:var(--green); }.quality-summary span { color:var(--muted); font-size:.78rem; }.quality-table td:first-child { text-align:left; }.quality-table small { display:block; }.quality-table td:not(:first-child) { font-size:.82rem; }
|
||||
.app-launcher { display:grid; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)); gap:1rem; padding-bottom:6rem; }.app-launcher a { position:relative; min-height:14rem; padding:1.4rem; color:var(--ink); background:var(--card); border:1px solid var(--line); text-decoration:none; }.app-launcher h2 { margin:.45rem 0; }.app-launcher strong { position:absolute; top:1.2rem; right:1.2rem; font:600 1.5rem var(--serif); color:var(--green); }.app-launcher p { color:var(--muted); }.entity-list { padding-bottom:6rem; }.entity-list-head,.entity-list > a { display:grid; grid-template-columns:minmax(16rem,2fr) repeat(3,minmax(6rem,1fr)); align-items:center; gap:1rem; padding:.8rem 1rem; border-bottom:1px solid var(--line); }.entity-list-head { color:var(--muted); font-size:.68rem; text-transform:uppercase; letter-spacing:.08em; }.entity-list > a { color:var(--ink); text-decoration:none; }.entity-list > a:hover { background:var(--card); }.entity-list small { display:block; color:var(--muted); }.empty-state { padding:3rem; text-align:center; background:var(--card); border:1px solid var(--line); }
|
||||
.ingredient-list-tools { display:flex; justify-content:flex-end; padding-block:1rem; }.filter-menu { position:relative; }.filter-menu summary { padding:.65rem .85rem; color:white; background:var(--green); cursor:pointer; font-weight:700; list-style:none; }.filter-menu summary::-webkit-details-marker { display:none; }.filter-menu form { position:absolute; z-index:4; right:0; width:min(20rem,calc(100vw - 2rem)); padding:1rem; background:var(--card); border:1px solid var(--line); box-shadow:0 .75rem 2rem rgba(30,35,25,.14); }.filter-menu label { display:flex; align-items:center; gap:.55rem; padding:.35rem 0; }.filter-menu fieldset { margin:.75rem 0; padding:.75rem; border:1px solid var(--line); }.filter-menu legend { color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.06em; }.filter-menu form > div { display:flex; align-items:center; gap:1rem; }.filter-menu button { padding:.6rem .8rem; color:white; background:var(--green); border:0; cursor:pointer; font-weight:700; }.ingredient-entity-list .entity-list-head,.ingredient-entity-list > a { grid-template-columns:minmax(16rem,1fr) 7rem; }.attention-reasons { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.4rem; }.attention-reasons em { padding:.18rem .4rem; color:#8b321f; background:#f3dfd4; font-size:.68rem; font-style:normal; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.directory-workspace { padding-block:28px 72px; }.workspace-pills { display:flex; align-items:center; flex-wrap:wrap; gap:10px; overflow:visible; padding:0 0 28px; }.workspace-pills > a { display:inline-flex; align-items:center; gap:8px; flex:none; min-height:42px; padding:7px 13px; color:rgba(0,0,0,0.87); background:white; border:1px solid #e5e7ef; border-radius:999px; text-decoration:none; font-weight:400; box-shadow:0 1px 2px rgba(15,23,42,.03); }.workspace-pills > a.active { background:#DBE4FF; border-color:#DBE4FF; }.workspace-pills > a small { color:#a0a7ba; font-size:.8rem; font-weight:500; }.workspace-pill-icon { display:grid; place-items:center; width:22px; height:22px; color:white; background:#48558d; border-radius:50%; font-size:.7rem; }.workspace-pill-icon.ingredient,.workspace-pill-icon.purchase { background:#489d96; }.workspace-pill-icon.book { background:#6847ed; }.workspace-pills .filter-menu { flex:none; margin-left:auto; }.workspace-pills .filter-menu summary { color:#172033; background:transparent; white-space:nowrap; }.ingredient-directory { overflow:hidden; background:white; border:1px solid #eef0f5; }.ingredient-selection-bar { display:grid; grid-template-columns:28px 1fr auto; align-items:center; min-height:58px; gap:18px; padding:0 22px; border-bottom:1px solid #eef0f5; }.ingredient-selection-bar input,.ingredient-directory-row > input { width:17px; height:17px; margin:0; accent-color:#4868f7; }.ingredient-selection-bar strong { color:#5366fa; font-size:.9rem; }.ingredient-selection-bar button { color:#64748b; background:transparent; border:0; cursor:pointer; }.ingredient-directory-row { display:grid; grid-template-columns:28px 34px minmax(14rem,1fr) 10rem 28px; align-items:center; min-height:60px; gap:18px; padding:0 22px; border-bottom:1px solid #eef0f5; }.ingredient-directory-row:last-child { border-bottom:0; }.ingredient-directory-row:hover { background:#f8faff; }.ingredient-directory-row.selected { background:#edf2ff; }.ingredient-type-icon { display:grid; place-items:center; width:26px; height:26px; color:white; background:#489d96; border-radius:50%; font-size:.65rem; }.ingredient-directory-name { color:#111a49; text-decoration:none; }.ingredient-directory-name strong { font-size:.9rem; }.ingredient-directory-name small { display:flex; gap:5px; margin-top:3px; }.ingredient-directory-name em { padding:2px 5px; color:#9a3412; background:#fff0e8; border-radius:3px; font-size:.62rem; font-style:normal; font-weight:700; text-transform:uppercase; }.ingredient-usage { color:#8b93a7; font-size:.82rem; text-align:right; }.ingredient-row-menu { color:#a0a7ba; font-size:1.45rem; line-height:1; text-align:center; text-decoration:none; }
|
||||
.workspace-directory-list { overflow:hidden; background:white; border:1px solid #eef0f5; }.workspace-directory-heading,.workspace-directory-row { display:grid; grid-template-columns:34px minmax(16rem,1fr) 9rem 10rem 28px; align-items:center; gap:18px; padding:0 22px; }.workspace-directory-heading { min-height:42px; color:#8b93a7; border-bottom:1px solid #eef0f5; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; }.workspace-directory-heading strong { grid-column:2; }.workspace-directory-row { min-height:64px; color:#111a49; border-bottom:1px solid #eef0f5; text-decoration:none; }.workspace-directory-row:last-child { border-bottom:0; }.workspace-directory-row:hover { background:#f8faff; }.workspace-directory-row > span:nth-child(2) { display:flex; flex-direction:column; min-width:0; }.workspace-directory-row small { overflow:hidden; color:#8b93a7; text-overflow:ellipsis; white-space:nowrap; }.workspace-directory-row b { color:#a0a7ba; font-size:1.35rem; text-align:center; }.workspace-directory-heading:has(+ .books),.workspace-directory-row.books { grid-template-columns:34px minmax(16rem,1fr) 9rem 28px; }.workspace-directory-heading:has(+ .purchases),.workspace-directory-row.purchases { grid-template-columns:34px minmax(14rem,1fr) minmax(10rem,1fr) 9rem 7rem 28px; }
|
||||
.workspace-search-tools { display:flex; align-items:flex-start; gap:10px; margin-bottom:18px; }.workspace-global-search { display:flex; align-items:center; gap:10px; flex:1; max-width:680px; min-height:44px; padding:0 14px; background:white; border:1px solid #dfe3ec; border-radius:7px; }.workspace-global-search:focus-within { border-color:#7185ec; box-shadow:0 0 0 3px #e9efff; }.workspace-global-search > span { color:#818aa0; font-size:1.35rem; }.workspace-global-search input[type="search"] { flex:1; min-width:0; padding:10px 0; color:#111a49; background:transparent; border:0; outline:0; font:inherit; }.workspace-global-search a { color:#818aa0; font-size:1.35rem; text-decoration:none; }.search-type-filter { position:relative; }.search-type-filter summary { min-height:44px; padding:11px 14px; color:#172033; background:white; border:1px solid #dfe3ec; border-radius:7px; cursor:pointer; font-weight:700; list-style:none; white-space:nowrap; }.search-type-filter summary::-webkit-details-marker { display:none; }.search-type-filter form { position:absolute; z-index:5; right:0; width:220px; padding:12px; background:white; border:1px solid #dfe3ec; border-radius:7px; box-shadow:0 .75rem 2rem rgba(30,35,25,.14); }.search-type-filter label { display:flex; align-items:center; gap:8px; padding:6px 2px; }.search-type-filter form > div { display:flex; align-items:center; gap:12px; margin-top:10px; }.search-type-filter button { padding:7px 10px; color:white; background:#48558d; border:0; border-radius:5px; cursor:pointer; font-weight:700; }.workspace-search-results > p { color:#687086; font-size:.86rem; }.workspace-search-results > div { overflow:hidden; background:white; border:1px solid #eef0f5; }.workspace-search-results > div > a { display:grid; grid-template-columns:34px minmax(12rem,1fr) 9rem 24px; align-items:center; gap:16px; min-height:62px; padding:0 22px; color:#111a49; border-bottom:1px solid #eef0f5; text-decoration:none; }.workspace-search-results > div > a:last-child { border-bottom:0; }.workspace-search-results > div > a:hover { background:#f8faff; }.workspace-search-results > div > a > span:nth-child(2) { display:flex; flex-direction:column; min-width:0; }.workspace-search-results small { overflow:hidden; color:#8b93a7; text-overflow:ellipsis; white-space:nowrap; }.workspace-search-results em { color:#8b93a7; font-size:.75rem; font-style:normal; text-align:right; text-transform:uppercase; }.workspace-search-results b { color:#a0a7ba; font-size:1.3rem; }
|
||||
.workspace-tabs { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1.5rem; }.workspace-tabs a { padding:.55rem .75rem; color:var(--ink); background:var(--card); border:1px solid var(--line); text-decoration:none; }.ingredient-workspace { display:grid; gap:1.25rem; padding-bottom:6rem; }.workspace-panel { scroll-margin-top:1rem; padding:1.25rem; background:var(--card); border:1px solid var(--line); }.workspace-panel h2 { margin-top:0; }.workspace-panel > label { display:grid; grid-template-columns:minmax(9rem,1fr) 3fr; gap:1rem; align-items:center; margin:.75rem 0; color:var(--muted); }.workspace-panel input,.workspace-panel textarea,.workspace-panel select { padding:.65rem; color:var(--ink); background:var(--paper); border:1px solid var(--line); font:1rem var(--sans); }.inline-form { display:flex; flex-wrap:wrap; gap:.55rem; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--line); }.inline-form button { padding:.7rem; color:white; background:var(--green); border:1px solid var(--green); cursor:pointer; }.purchase-row { display:grid; grid-template-columns:minmax(12rem,1fr) auto auto; gap:1rem; align-items:center; padding:.7rem 0; border-bottom:1px solid var(--line); }.purchase-row small { display:block; color:var(--muted); }
|
||||
.panel-intro { color:var(--muted); }.ingredient-nutrition { margin-top:1rem; padding:1rem; background:var(--paper); border:1px solid var(--line); }.nutrition-mapping-head { display:flex; justify-content:space-between; gap:1rem; align-items:start; padding-bottom:.75rem; border-bottom:3px solid var(--green); }.nutrition-mapping-head small { display:block; margin-top:.2rem; color:var(--muted); text-transform:capitalize; }.nutrition-mapping-head a { white-space:nowrap; }.ingredient-nutrition dl { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); margin:0; }.ingredient-nutrition dl div { display:flex; justify-content:space-between; gap:1rem; padding:.55rem; border-bottom:1px solid var(--line); }.ingredient-nutrition dt,.ingredient-nutrition dd { margin:0; }.ingredient-nutrition dd { font-weight:700; }.ingredient-nutrition footer { display:flex; flex-wrap:wrap; gap:.5rem 1.2rem; padding-top:.75rem; color:var(--muted); font-size:.72rem; }
|
||||
.purchase-editor { border-top:1px solid var(--line); }.purchase-editor:last-child { border-bottom:1px solid var(--line); }.purchase-editor > summary { display:flex; justify-content:space-between; gap:1rem; align-items:center; padding:1rem .25rem; cursor:pointer; }.purchase-editor > summary > span:last-child { text-align:right; }.purchase-editor summary small { display:block; margin-top:.2rem; color:var(--muted); }.add-purchase > summary { color:var(--green); font-weight:700; }.purchase-item-form { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.85rem; padding:1rem; margin-bottom:1rem; background:var(--paper); border:1px solid var(--line); }.purchase-item-form label { display:flex; flex-direction:column; gap:.3rem; min-width:0; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; }.purchase-item-form .purchase-name { grid-column:span 2; }.purchase-item-form input,.purchase-item-form select { width:100%; }.input-suffix { display:flex; align-items:center; }.input-suffix input { min-width:0; }.input-suffix > span { padding:.65rem; color:var(--ink); background:var(--card); border:1px solid var(--line); border-left:0; }.purchase-rate { grid-column:span 3; display:flex; align-items:center; gap:1rem; margin:0; padding:.65rem; background:var(--card); }.purchase-rate span { color:var(--muted); }.purchase-form-actions { display:flex; justify-content:flex-end; align-items:end; }.purchase-form-actions button { padding:.7rem; color:white; background:var(--green); border:1px solid var(--green); cursor:pointer; font-weight:700; }.price-history { grid-column:1/-1; }.price-history summary { cursor:pointer; color:var(--green); font-size:.8rem; font-weight:700; }.price-history table { margin-top:.5rem; }.price-history td { padding:.4rem; font-size:.8rem; }.price-history td:last-child { text-align:right; }
|
||||
.usda-id-form { display:flex; flex-wrap:wrap; gap:.65rem; align-items:end; }.usda-id-form label { display:flex; flex-direction:column; gap:.35rem; }.usda-id-form input { min-width:15rem; }.usda-id-form button { padding:.7rem; color:white; background:var(--green); border:1px solid var(--green); cursor:pointer; }.usda-id-form a { padding:.65rem 0; }
|
||||
.editor-layout { display:grid; grid-template-columns:minmax(0,2fr) minmax(16rem,1fr); gap:2rem; padding-bottom:6rem; }.editor-form fieldset { display:grid; gap:1rem; margin:0 0 1.5rem; padding:1.25rem; border:1px solid var(--line); background:var(--card); }.editor-form legend { padding:0 .5rem; font:600 1.35rem var(--serif); }.editor-form label { display:flex; flex-direction:column; gap:.4rem; color:var(--muted); font-size:.76rem; text-transform:uppercase; letter-spacing:.06em; }.editor-form input,.editor-form textarea,.editor-form select { width:100%; padding:.7rem; color:var(--ink); background:var(--paper); border:1px solid var(--line); font:1rem var(--sans); text-transform:none; letter-spacing:normal; }.editor-row { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }.editor-actions { display:flex; flex-wrap:wrap; gap:.75rem; }.editor-actions button { padding:.8rem 1rem; border:1px solid var(--green); background:var(--green); color:white; font-weight:700; cursor:pointer; }.editor-actions .secondary { background:transparent; color:var(--green); }.success-notice { margin-bottom:1rem; padding:1rem; color:#204f31; background:#dcebdc; border-left:4px solid #39734c; }
|
||||
.structure-editor button { padding:.55rem .7rem; border:1px solid var(--line); background:var(--card); cursor:pointer; }.structure-editor .section-heading > button,.add-line button { color:white; background:var(--green); }.component-editor { margin:0 0 1.5rem; padding:1rem; border:1px solid var(--line); }.component-editor legend { padding:0 .5rem; font:600 1.25rem var(--serif); }.editor-inline,.add-line { display:flex; gap:.5rem; margin-bottom:1rem; }.editor-inline input,.add-line select,.editor-items input[type="number"],.editor-items select,.method-editor textarea { width:100%; padding:.55rem; color:var(--ink); background:var(--paper); border:1px solid var(--line); }.editor-inline input { font-weight:700; }.editor-items { min-width:60rem; table-layout:fixed; }.editor-items th,.editor-items td { vertical-align:middle; text-align:left; }.editor-items th:nth-child(2),.editor-items td:nth-child(2) { width:7rem; text-align:right; }.editor-items td:nth-child(2) input { text-align:right; }.editor-items th:nth-child(3),.editor-items td:nth-child(3) { width:12rem; }.editor-items th:nth-child(4),.editor-items td:nth-child(4) { width:9rem; }.editor-items th:nth-child(5),.editor-items td:nth-child(5) { width:6rem; text-align:center; }.editor-items th:last-child,.editor-items td:last-child { width:8.5rem; text-align:right; }.editor-items input[type="number"],.editor-items select { min-width:0; }.editor-items td:last-child .row-actions button { width:2.1rem; height:2.1rem; padding:0; }.row-actions { display:flex; gap:.25rem; justify-content:flex-end; }.structure-editor button.danger { color:#8b321f; }.inline-check { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; white-space:nowrap; font-size:.76rem; }.inline-check input { width:auto; min-width:0; margin:0; }.secondary-action { margin-bottom:1.5rem; }.method-editor ol { padding:0; list-style:none; counter-reset:method; }.method-editor li { display:grid; grid-template-columns:1fr auto; gap:.5rem; margin-bottom:.75rem; counter-increment:method; }.method-editor li::before { content:counter(method); display:none; }
|
||||
.editor-entity-link { color:var(--green); text-decoration-thickness:1px; text-underline-offset:3px; }
|
||||
@media (max-width:900px) { .recipe-grid,.taxonomy-grid { grid-template-columns:repeat(2,1fr); }.recipe-header { grid-template-columns:1fr; gap:25px; }.recipe-facts { max-width:500px; }.purchase-item-form { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:620px) { .site-header { align-items:flex-start; padding-block:17px; }.site-header nav { gap:8px 14px; flex-wrap:wrap; justify-content:flex-end; }.brand span { display:none; }.recipe-grid,.taxonomy-grid { grid-template-columns:1fr; }.calculator-heading { align-items:flex-start; gap:25px; flex-direction:column; }.formula-total { justify-content:space-between; gap:15px; }.recipe-page { padding-top:45px; }.recipe-header h1 { font-size:3.2rem; }.entity-list-head { display:none; }.entity-list > a { grid-template-columns:1fr 1fr; }.editor-layout { grid-template-columns:1fr; }.workspace-panel > label { grid-template-columns:1fr; }.purchase-row { grid-template-columns:1fr; }.purchase-item-form { grid-template-columns:1fr; }.purchase-item-form .purchase-name,.purchase-rate { grid-column:auto; }.purchase-editor > summary { align-items:flex-start; }.purchase-editor > summary > span:last-child { text-align:left; } }
|
||||
|
||||
html { line-height:1.5; }
|
||||
body { background:var(--paper); }
|
||||
.shell { width:min(1180px,calc(100% - 32px)); }
|
||||
.site-header { min-height:64px; padding-inline:max(16px,calc((100vw - 1180px)/2)); background:rgba(255,255,255,.96); box-shadow:0 1px 2px rgba(15,23,42,.04); }
|
||||
.brand { gap:10px; font-size:1rem; font-weight:700; }
|
||||
.brand img { width:28px; height:28px; }
|
||||
nav { gap:20px; }
|
||||
nav a { font-size:.875rem; }
|
||||
h1,h2,h3 { font-family:var(--sans); line-height:1.2; letter-spacing:-.02em; }
|
||||
h1 { font-size:clamp(2rem,4vw,3rem); }
|
||||
h2 { font-size:clamp(1.5rem,2.5vw,2rem); }
|
||||
h3 { font-size:1.125rem; }
|
||||
.eyebrow { margin-bottom:8px; color:var(--muted); font-size:.7rem; letter-spacing:.1em; }
|
||||
.button,.primary-command { border-radius:6px; }
|
||||
.button { padding:10px 16px; font-size:.875rem; }
|
||||
.button.primary,.primary-command { background:var(--green); border-color:var(--green); }
|
||||
.section-block { padding-block:48px; }
|
||||
.section-heading { margin-bottom:20px; align-items:center; }
|
||||
.recipe-grid { gap:16px; }
|
||||
.recipe-card,.taxonomy-grid a,.candidate-card,.workspace-panel,.derived-card,.app-launcher a { border-radius:8px; box-shadow:0 1px 2px rgba(15,23,42,.04); }
|
||||
.recipe-card { min-height:190px; padding:20px; }
|
||||
.recipe-card h2 { margin:24px 0 8px; font-size:1.25rem; }
|
||||
.page-heading { padding-block:48px 28px; }
|
||||
.page-heading h1 { margin-bottom:8px; }
|
||||
.page-heading p:last-child { font-size:.95rem; }
|
||||
.recipe-page { padding-block:48px 72px; }
|
||||
.recipe-header { grid-template-columns:1fr 260px; gap:48px; padding-bottom:36px; }
|
||||
.recipe-header h1 { margin:12px 0; font-size:clamp(2rem,4vw,3rem); }
|
||||
.workspace-command-bar { margin-bottom:1.25rem; }
|
||||
.mode-indicator,.workspace-tabs a,.primary-command { border-radius:6px; }
|
||||
.recipe-task-tabs { top:64px; background:rgba(248,250,252,.96); }
|
||||
.calculator { margin-top:44px; }
|
||||
th,td { padding:11px 10px; }
|
||||
th { font-size:.7rem; letter-spacing:.06em; }
|
||||
.formula-total { padding:14px 10px; background:#eef2ff; color:var(--ink); border-bottom:1px solid var(--line); }
|
||||
.equipment-list li { border-radius:6px; }
|
||||
.preparation,.notes,.additional-details { margin-top:56px; }
|
||||
.review-toolbar { top:64px; border-radius:8px; background:rgba(248,250,252,.96); }
|
||||
.entity-list > a:hover { background:#f1f5f9; }
|
||||
.filter-menu summary,.filter-menu button,.inline-form button,.purchase-form-actions button,.editor-actions button { border-radius:6px; }
|
||||
.workspace-panel { padding:1.25rem; }
|
||||
.workspace-panel input,.workspace-panel textarea,.workspace-panel select,.editor-form input,.editor-form textarea,.editor-form select,.editor-inline input,.add-line select,.editor-items input[type="number"],.editor-items select,.method-editor textarea { border-radius:6px; background:white; }
|
||||
.component-editor { border-radius:8px; background:var(--card); }
|
||||
.structure-editor button { border-radius:6px; }
|
||||
.success-notice,.notice { border-radius:6px; }
|
||||
.purchase-item-form,.ingredient-nutrition { border-radius:6px; background:#f8fafc; }
|
||||
.app-launcher a { min-height:11rem; }
|
||||
|
||||
@media (max-width:620px) {
|
||||
.shell { width:min(100% - 24px,1180px); }
|
||||
.recipe-page { padding-top:32px; }
|
||||
.recipe-header h1 { font-size:2.25rem; }
|
||||
.page-heading { padding-block:36px 24px; }
|
||||
}
|
||||
.workspace-new-menu { position:relative; margin-left:auto; }
|
||||
.workspace-new-menu > summary { display:flex; align-items:center; gap:5px; min-height:44px; padding:9px 16px; color:white; background:#4868f7; border-radius:22px; cursor:pointer; font-weight:700; list-style:none; white-space:nowrap; }
|
||||
.workspace-new-menu > summary::-webkit-details-marker { display:none; }
|
||||
.workspace-new-menu > nav { position:absolute; z-index:6; top:52px; right:0; width:260px; padding:10px 0; background:white; border:1px solid #dfe3ec; border-radius:7px; box-shadow:0 .75rem 2rem rgba(30,35,25,.16); }
|
||||
.workspace-new-menu > nav a { display:grid; grid-template-columns:28px 1fr; align-items:center; gap:14px; min-height:54px; padding:8px 20px; color:#111a49; text-decoration:none; }
|
||||
.workspace-new-menu > nav a:hover { background:#f8faff; }
|
||||
.create-entity { max-width:720px; padding-bottom:72px; }
|
||||
.entity-detail-shell,.recipe-detail-shell { width:min(1500px,calc(100% - 48px)); margin:0 auto; padding:24px 0 72px; color:#101a4a; }
|
||||
.entity-detail-header { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; min-height:108px; border-bottom:1px solid #e8eaf0; }.entity-detail-header h1 { margin:8px 0 2px; font:700 clamp(1.7rem,2.4vw,2.35rem) var(--sans); letter-spacing:-.035em; }.entity-detail-header p { margin:0; color:#858da2; }.entity-breadcrumb a { color:#26346d; font-size:.82rem; text-decoration:none; }.identity-edit { position:relative; }.identity-edit > summary { padding:8px 15px; color:#4868f7; border:1px solid #4868f7; border-radius:20px; cursor:pointer; font-weight:700; list-style:none; }.identity-edit > summary::-webkit-details-marker { display:none; }.identity-edit > form { position:absolute; z-index:8; right:0; width:min(390px,calc(100vw - 32px)); padding:18px; background:white; border:1px solid #dfe3ec; border-radius:7px; box-shadow:0 .75rem 2rem rgba(30,35,25,.16); }.identity-edit label { display:grid; gap:4px; margin-bottom:10px; color:#737c91; font-size:.75rem; }.identity-edit input,.identity-edit select,.identity-edit textarea { width:100%; padding:9px; border:1px solid #dfe3ec; }.identity-edit button { padding:9px 12px; color:white; background:#4868f7; border:0; border-radius:5px; font-weight:700; }.entity-notice { margin:14px 0; }.entity-detail-grid { display:grid; grid-template-columns:minmax(0,1.02fr) minmax(0,.98fr); min-height:720px; }.entity-primary { padding:48px 42px 40px 0; border-right:1px solid #e8eaf0; }.entity-secondary { min-width:0; padding-left:32px; }.entity-section { margin-bottom:54px; }.entity-section h2,.entity-tab-panel h2 { margin:0 0 8px; font:700 1.35rem var(--sans); }.prep-action-table { margin-top:34px; }.prep-action-table th,.prep-action-table td { padding:13px 8px; text-align:left; border-bottom:1px solid #edf0f5; }.prep-action-table th { color:#9098aa; font-size:.72rem; font-weight:500; }.inline-editor { margin-top:20px; }.inline-editor > summary { color:#4868f7; cursor:pointer; font-weight:700; list-style:none; }.additional-card { min-height:260px; padding:28px; border:1px solid #e8eaf0; }.additional-card > details { padding:16px 0; }.additional-card summary { cursor:pointer; font-weight:700; }.additional-card summary small { margin-left:5px; color:#9aa2b5; }.additional-card ul { padding-left:20px; }.additional-card li { display:flex; justify-content:space-between; gap:10px; padding:6px 0; }.entity-tab-panel { display:none; padding:42px 0; background:transparent; border:0; }.entity-tab-panel.active { display:block; }.equivalency-list p { display:grid; grid-template-columns:1fr auto 1fr; gap:12px; padding:14px 4px; border-bottom:1px solid #e8eaf0; }.equivalency-list p small { grid-column:1/-1; color:#8b93a7; }.entity-secondary .purchase-item-form { grid-template-columns:repeat(2,minmax(0,1fr)); }.entity-secondary .purchase-name,.entity-secondary .purchase-rate { grid-column:1/-1; }
|
||||
.recipe-detail-shell { width:min(1600px,calc(100% - 48px)); }.recipe-detail-shell .entity-detail-header { min-height:92px; }.recipe-overview-strip { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; padding:18px 0; }.recipe-overview-form { flex:1; }.recipe-overview-form > details > summary { color:#4868f7; cursor:pointer; font-weight:700; }.recipe-overview-form fieldset { max-width:720px; margin-top:15px; }.recipe-yield-summary { display:flex; align-items:baseline; gap:12px; min-width:260px; padding:12px 16px; border:1px solid #e8eaf0; }.recipe-yield-summary span,.recipe-yield-summary small { color:#8b93a7; }.recipe-structure-workspace { border-top:1px solid #e8eaf0; }.recipe-structure-workspace .structure-editor { display:grid; grid-template-columns:minmax(0,1fr) minmax(420px,.92fr); column-gap:32px; }.recipe-structure-workspace .section-heading { grid-column:1/-1; }.recipe-structure-workspace .component-editor,.recipe-structure-workspace > .structure-editor > .secondary-action { grid-column:1; }.recipe-structure-workspace .method-editor { grid-column:2; grid-row:2 / span 40; min-width:0; padding-left:32px; border-left:1px solid #e8eaf0; }.recipe-structure-workspace .component-editor { border-color:#e8eaf0; background:white; }.recipe-structure-workspace .editor-items { min-width:46rem; }.recipe-structure-workspace .method-editor li { padding:15px; border:1px solid #e8eaf0; }.recipe-history { margin-top:30px; padding:18px; border:1px solid #e8eaf0; }.recipe-history summary { cursor:pointer; font-weight:700; }.recipe-history li span,.recipe-history li small { display:block; color:#8b93a7; }
|
||||
.immersive-main { min-height:100vh; background:#fff; }.immersive-main .entity-detail-shell,.immersive-main .recipe-detail-shell { width:100%; max-width:none; margin:0; padding:0 0 72px; }.detail-utility { display:flex; align-items:center; justify-content:flex-end; gap:18px; min-height:58px; padding:7px 28px; border-bottom:1px solid #e8eaf0; }.detail-utility form { width:min(560px,42vw); }.detail-utility input { width:100%; min-height:42px; padding:8px 14px; border:1px solid #e5e8ef; border-radius:4px; font:inherit; }.detail-utility > a { color:#26346d; text-decoration:none; font-weight:700; }.detail-utility .detail-new { padding:9px 16px; color:white; background:#4868f7; border-radius:21px; }.immersive-main .entity-detail-header { width:52%; min-height:128px; padding:26px 38px; border-right:1px solid #e8eaf0; }.immersive-main .entity-notice { width:calc(52% - 76px); margin:12px 38px; }.immersive-main .entity-detail-grid { grid-template-columns:52% 48%; }.immersive-main .entity-primary { padding:48px 38px; }.immersive-main .entity-secondary { padding:0 30px; }.immersive-main .recipe-detail-shell .entity-detail-header { width:52%; padding:22px 38px; }.immersive-main .recipe-workspace-tabs { width:48%; margin:-92px 0 30px 52%; min-height:92px; align-items:end; padding:0 22px; }.immersive-main .recipe-overview-strip { width:52%; min-height:150px; padding:20px 38px; border-right:1px solid #e8eaf0; }.immersive-main .recipe-structure-workspace { padding:0 38px; }.immersive-main .recipe-structure-workspace .structure-editor { grid-template-columns:minmax(0,52fr) minmax(420px,48fr); column-gap:0; }.immersive-main .recipe-structure-workspace .section-heading { padding-right:32px; }.immersive-main .recipe-structure-workspace .component-editor,.immersive-main .recipe-structure-workspace > .structure-editor > .secondary-action { margin-right:32px; }.immersive-main .recipe-structure-workspace .method-editor { padding-left:32px; }.immersive-main .recipe-history { margin-inline:38px; }
|
||||
.immersive-main .entity-detail-shell,.immersive-main .recipe-detail-shell { position:relative; }.immersive-main .detail-utility { position:absolute; z-index:10; top:0; right:0; width:48%; height:58px; background:#fff; }.detail-new-menu { position:relative; }.detail-new-menu > summary { padding:9px 16px; color:white; background:#4868f7; border-radius:21px; cursor:pointer; font-weight:700; list-style:none; white-space:nowrap; }.detail-new-menu > summary::-webkit-details-marker { display:none; }.detail-new-menu > div { position:absolute; top:46px; right:0; width:220px; padding:8px 0; background:#fff; border:1px solid #e5e8ef; border-radius:6px; box-shadow:0 12px 30px rgba(22,30,65,.15); }.detail-new-menu > div a { display:flex; gap:12px; padding:13px 18px; color:#111a49; text-decoration:none; }.detail-new-menu > div a:hover { background:#f7f8fc; }.immersive-main .entity-detail-header { min-height:164px; padding-top:24px; }.immersive-main .entity-detail-grid { border-top:1px solid #e8eaf0; }.immersive-main .entity-secondary { margin-top:-106px; background:#fff; }.immersive-main .entity-tabs { min-height:58px; }.immersive-main .recipe-detail-shell .entity-detail-header { min-height:116px; padding-top:20px; }.immersive-main .recipe-workspace-tabs { min-height:58px; margin:-58px 0 28px 52%; }.immersive-main .recipe-workspace-tabs a { padding-block:17px; }.immersive-main .recipe-overview-strip { min-height:126px; }.immersive-main .recipe-structure-workspace { border-top:0; }.immersive-main .recipe-structure-workspace .structure-editor { border-top:1px solid #e8eaf0; }.immersive-main .recipe-structure-workspace .section-heading { min-height:76px; margin:0; padding:18px 32px 12px 0; }.immersive-main .recipe-structure-workspace .section-heading h2 { margin:2px 0; font:700 1.35rem var(--sans); }
|
||||
.edit-command { padding:8px 15px; color:#4868f7; border:1px solid #4868f7; border-radius:20px; text-decoration:none; font-weight:700; }.recipe-view-workspace { display:grid; grid-template-columns:52% 48%; border-top:1px solid #e8eaf0; }.recipe-view-formula { min-width:0; padding:26px 32px 48px 38px; border-right:1px solid #e8eaf0; }.recipe-view-formula .calculator { padding:0; }.recipe-view-formula .calculator-heading { display:flex; align-items:baseline; gap:24px; margin-bottom:22px; }.recipe-view-formula .calculator-heading h2 { margin:0; font:700 1.4rem var(--sans); }.recipe-view-formula .calculator-heading .eyebrow { display:none; }.recipe-view-formula .basis-input { min-width:0; }.recipe-view-formula .formula-component { margin:0 0 14px; border:0; background:transparent; }.recipe-view-formula .formula-component h3 { margin:0; padding:8px 0; background:transparent; border-bottom:1px solid #f3f3f3; font:500 1.125rem var(--sans); }.recipe-view-formula .recipe-ingredients th { color:#8b93a7; background:white; font-size:.72rem; font-weight:500; }.recipe-view-formula .recipe-ingredients td { border:0; }.recipe-view-formula .formula-total { margin-top:12px; }.recipe-view-details { min-width:0; padding:28px 30px 60px; }.recipe-view-method h2,.recipe-derived-panel > h2 { font:700 1.4rem var(--sans); }.recipe-view-method h2 small { color:#9aa2b5; font-size:.85rem; }.recipe-view-method ol { padding:0; list-style:none; }.recipe-derived-panel { margin-top:46px; padding-top:26px; border-top:1px solid #e8eaf0; }.recipe-derived-panel dl { margin:0; }.recipe-derived-panel dl > div { display:flex; justify-content:space-between; padding:12px 4px; border-bottom:1px solid #edf0f5; }.recipe-derived-panel dd { margin:0; font-weight:700; }.recipe-derived-panel .nutrition-wrap { display:block; }.recipe-derived-panel .nutrition-label { max-width:520px; }
|
||||
.ingredient-identity-form { display:grid; grid-template-columns:1fr 180px; gap:12px; margin:0 0 42px; padding:20px; background:#fafbfe; border:1px solid #e8eaf0; }.ingredient-identity-form h2 { grid-column:1/-1; margin:0 0 6px; font:700 1.2rem var(--sans); }.ingredient-identity-form label { display:grid; gap:5px; color:#788197; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; }.ingredient-identity-form label:nth-of-type(3),.ingredient-identity-form label:nth-of-type(4) { grid-column:1/-1; }.ingredient-identity-form input,.ingredient-identity-form select,.ingredient-identity-form textarea { width:100%; padding:9px; color:#111a49; background:#fff; border:1px solid #dfe3ec; font:1rem var(--sans); text-transform:none; letter-spacing:normal; }.ingredient-identity-form button { justify-self:start; padding:9px 13px; color:white; background:#4868f7; border:0; border-radius:5px; font-weight:700; }.primary-command { padding:9px 16px; color:white; background:#43b99f; border-radius:21px; text-decoration:none; font-weight:700; }
|
||||
.ingredient-identity-data { display:none; }
|
||||
.ingredient-prep-section .panel-intro { max-width:650px; line-height:1.55; }
|
||||
.prep-action-table td:first-child small { display:block; margin-top:3px; color:#8d93a7; font-weight:400; }
|
||||
.prep-action-editor .inline-form { display:grid; align-items:end; grid-template-columns:minmax(150px,1.2fr) minmax(110px,.55fr) minmax(160px,1fr) auto; }
|
||||
.prep-action-editor label { display:grid; gap:5px; color:#8b92a6; font-size:11px; }
|
||||
.prep-action-editor label input,.prep-action-editor label select { width:100%; }
|
||||
.percent-input { display:flex; align-items:center; min-width:0; border:1px solid #dfe3ec; background:#fff; }
|
||||
.percent-input input { min-width:0; border:0 !important; }
|
||||
.percent-input i { padding-right:10px; color:#8b92a6; font-style:normal; }
|
||||
.field-help { margin:10px 0 0; color:#9298aa; font-size:12px; line-height:1.5; }
|
||||
.alias-editor { display:grid; gap:7px; margin-top:14px; color:#8b92a6; font-size:11px; }
|
||||
.alias-editor textarea { width:100%; padding:10px 12px; resize:vertical; border:1px solid #dfe3ec; border-radius:3px; color:#111a49; font:14px var(--sans); }
|
||||
@media (max-width:720px) { .prep-action-table { display:block; overflow-x:auto; }.prep-action-table th,.prep-action-table td { min-width:90px; white-space:nowrap; }.prep-action-editor .inline-form { grid-template-columns:1fr; } }
|
||||
.ingredient-title-editor { overflow:hidden; resize:none; field-sizing:content; }
|
||||
.ingredient-prep-section .inline-editor > summary { display:inline-flex; align-items:center; min-height:40px; margin-top:5px; padding:8px 16px; color:#202962; background:#fff; border:1px solid #202962; border-radius:22px; font-size:14px; font-weight:600; }
|
||||
.ingredient-additional-edit { padding:42px 32px 62px !important; border:1px solid #f3f3f3 !important; border-radius:4px; }
|
||||
.ingredient-detail-fields { display:grid; gap:24px; margin-top:28px; }
|
||||
.ingredient-detail-fields > label { display:grid; gap:8px; color:#3c4679; font-size:14px; }
|
||||
.ingredient-detail-fields > label > small { margin-top:-5px; color:#a5a9c1; font-size:11px; }
|
||||
.ingredient-detail-fields input,.ingredient-detail-fields textarea { width:100%; min-height:44px; padding:10px 12px; color:#050841; background:#fff; border:1px solid #ececec; border-bottom-color:#b5b5b5; border-radius:0; font:15px var(--sans); resize:vertical; }
|
||||
.ingredient-detail-fields textarea:focus,.ingredient-detail-fields input:focus { background:#f1f5fe; border-bottom-color:#3d5df6; outline:0; }
|
||||
.ingredient-tag-view,.ingredient-description-view { padding:16px 0; border-bottom:1px solid #eeeef3; }
|
||||
.ingredient-tag-view > strong,.ingredient-description-view > strong { display:block; margin-bottom:10px; color:#3c4679; font-size:13px; }
|
||||
.ingredient-tag-view > span { display:flex; flex-wrap:wrap; gap:7px; }
|
||||
.ingredient-tag-view i { padding:5px 11px; color:#202962; background:#f1f5fe; border-radius:16px; font-size:12px; font-style:normal; }
|
||||
.ingredient-description-view p { margin:0; color:#505773; line-height:1.55; }
|
||||
.ingredient-cost-edit { display:grid; gap:28px; }
|
||||
.ingredient-purchase-form { padding-bottom:28px; border-bottom:1px solid #f3f3f3; }
|
||||
.ingredient-purchase-form > header { display:flex; align-items:center; justify-content:space-between; gap:15px; margin-bottom:18px; }
|
||||
.ingredient-purchase-form h3 { margin:0; color:#3c4679; font-size:14px; font-weight:600; }
|
||||
.ingredient-purchase-form > header form { margin:0; }
|
||||
.ingredient-purchase-form > header button { padding:5px 7px; color:#f63d48; background:transparent; border:0; cursor:pointer; font-size:11px; }
|
||||
.ingredient-purchase-form .purchase-item-form { margin:0; padding:0; background:#fff; border:0; border-radius:0; }
|
||||
.ingredient-purchase-form .purchase-item-form label { color:#3c4679; font-size:11px; font-weight:500; letter-spacing:0; text-transform:none; }
|
||||
.ingredient-purchase-form .purchase-item-form input,.ingredient-purchase-form .purchase-item-form select { min-height:44px; padding:9px 10px; background:#ffffff; border-color:#ececec; border-radius:0; }
|
||||
.ingredient-purchase-form .purchase-form-actions button { padding:9px 16px; background:#3d5df6; border-color:#3d5df6; border-radius:20px; }
|
||||
.ingredient-cost-row { display:flex; align-items:center; justify-content:space-between; gap:20px; min-height:68px; padding:12px 0; border-bottom:1px solid #edf0f5; }
|
||||
.ingredient-cost-row > span:last-child { text-align:right; }
|
||||
.ingredient-cost-row strong,.ingredient-cost-row small { display:block; }
|
||||
.ingredient-cost-row small { margin-top:3px; color:#a5a9c1; font-size:11px; }
|
||||
.detail-actions-menu label { display:grid; gap:6px; padding:9px 14px; color:#a5a9c1; font-size:11px; }
|
||||
.detail-actions-menu select { width:250px; padding:9px; }
|
||||
.application-body .ingredient-actions-menu > div { width:290px; }
|
||||
@media (min-width:981px) { .application-body .entity-detail-shell .entity-primary { background:#ffffff; } }
|
||||
.application-body .entity-detail-shell .entity-detail-header { background:#ffffff; }
|
||||
.application-body .entity-detail-shell .entity-primary { padding-top:35px; }
|
||||
.application-body .entity-detail-shell .entity-section { margin-bottom:35px; }
|
||||
.application-body .entity-detail-shell .entity-section h2,.application-body .entity-detail-shell .entity-tab-panel h2 { color:#050841; font-size:22px; font-weight:700; line-height:1.25; }
|
||||
.application-body .entity-detail-shell .panel-intro { max-width:640px; margin:5px 0 20px; color:#95969c; font-size:14px; line-height:1.5; }
|
||||
.application-body .entity-detail-shell .entity-tabs { position:relative; min-height:58px; background:#fff; border-color:#edf0f5; }
|
||||
.application-body .entity-detail-shell .entity-tabs button { min-height:58px; padding:8px 12px; color:#050841; font-size:15px; font-weight:400; }
|
||||
.application-body .entity-detail-shell .entity-tabs button.active { color:#050841; background:#f1f5fe; border-bottom:2px solid #3d5df6; }
|
||||
.application-body .entity-detail-shell .entity-tab-panel { padding:35px 2px 60px; }
|
||||
.application-body .entity-detail-shell .prep-action-table { width:100%; margin-top:26px; table-layout:fixed; }
|
||||
.application-body .entity-detail-shell .prep-action-table th { height:40px; padding:8px 10px; color:#757677; background:#ffffff; border-color:#edf0f5; font-size:12px; }
|
||||
.application-body .entity-detail-shell .prep-action-table td { height:58px; padding:10px; color:#050841; background:#fff; border:1px solid #edf0f5; font-size:14px; }
|
||||
.application-body .entity-detail-shell .inline-editor > summary,.application-body .entity-detail-shell .add-purchase > summary { display:inline-flex; align-items:center; min-height:40px; padding:8px 16px; color:#202962; background:#fff; border:1px solid #202962; border-radius:22px; font-size:14px; font-weight:600; list-style:none; }
|
||||
.application-body .entity-detail-shell .inline-editor > summary::-webkit-details-marker,.application-body .entity-detail-shell .add-purchase > summary::-webkit-details-marker { display:none; }
|
||||
.application-body .entity-detail-shell .inline-form { gap:10px; margin-top:18px; padding-top:18px; border-color:#f3f3f3; }
|
||||
.application-body .entity-detail-shell .inline-form label { color:#3c4679; font-size:11px; font-weight:500; }
|
||||
.application-body .entity-detail-shell .inline-form input,.application-body .entity-detail-shell .inline-form select { min-height:44px; padding:9px 10px; color:#050841; background:#fff; border:1px solid #ececec; border-radius:0; font-size:14px; }
|
||||
.application-body .entity-detail-shell .inline-form input:focus,.application-body .entity-detail-shell .inline-form select:focus { background:#f1f5fe; border-color:#3d5df6; outline:0; }
|
||||
.application-body .entity-detail-shell .inline-form button,.application-body .entity-detail-shell .usda-id-form button { min-height:38px; padding:8px 16px; color:#fff; background:#3d5df6; border:0; border-radius:20px; font-size:13px; font-weight:600; }
|
||||
.application-body .equivalency-editor .inline-form { display:grid; grid-template-columns:90px minmax(120px,1fr) auto 90px minmax(120px,1fr) minmax(130px,1fr) auto; align-items:end; }
|
||||
.application-body .equivalency-editor .inline-form label { display:grid; gap:5px; }
|
||||
.application-body .equivalency-editor .inline-form > i { align-self:center; color:#a5a9c1; font-style:normal; }
|
||||
.application-body .equivalency-list { margin-top:8px; }
|
||||
.application-body .equivalency-list p { min-height:58px; margin:0; padding:12px 8px; border-color:#f3f3f3; color:#050841; }
|
||||
.application-body .equivalency-list p small { color:#a5a9c1; font-size:11px; }
|
||||
.application-body .equivalency-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; border-bottom:1px solid #f3f3f3; }
|
||||
.application-body .equivalency-row > p { border-bottom:0; }
|
||||
.application-body .equivalency-actions { display:flex; gap:4px; }
|
||||
.application-body .equivalency-actions form { margin:0; }
|
||||
.application-body .equivalency-actions button,.application-body .cancel-equivalency { padding:6px 8px; color:#3d5df6; background:transparent; border:0; cursor:pointer; font-size:11px; }
|
||||
.application-body .equivalency-actions form button { color:#f63d48; }
|
||||
.application-body .cancel-equivalency { align-self:end; min-height:38px; }
|
||||
.application-body .usda-id-form { gap:10px; margin:24px 0; padding:18px 0; border-block:1px solid #f3f3f3; }
|
||||
.application-body .usda-id-form label { gap:6px; color:#3c4679; font-size:11px; font-weight:500; }
|
||||
.application-body .usda-id-form input { min-height:44px; padding:9px 10px; background:#fff; border:1px solid #ececec; border-radius:0; font-size:14px; }
|
||||
.application-body .usda-id-form a,.application-body .nutrition-mapping-head a { color:#3d5df6; font-size:12px; font-weight:600; text-decoration:none; }
|
||||
.application-body .ingredient-nutrition { margin-top:20px; padding:0; background:#fff; border:0; border-radius:0; }
|
||||
.application-body .nutrition-mapping-head { padding:0 0 14px; border-bottom:1px solid #3d5df6; }
|
||||
.application-body .ingredient-nutrition dl div { min-height:44px; padding:10px 6px; border-color:#f3f3f3; font-size:13px; }
|
||||
.application-body .ingredient-nutrition footer { color:#a5a9c1; }
|
||||
.application-body .prep-edit-wrap { overflow-x:auto; margin-top:26px; }
|
||||
.application-body .prep-edit-table { min-width:700px; margin-top:0 !important; }
|
||||
.application-body .prep-edit-table th:first-child { width:31%; }
|
||||
.application-body .prep-edit-table th:nth-child(2) { width:15%; }
|
||||
.application-body .prep-edit-table th:last-child { width:44px; }
|
||||
.application-body .prep-edit-table select,.application-body .prep-edit-table input { width:100%; min-height:36px; padding:7px 8px; color:#050841; background:#fff; border:0; border-bottom:1px solid #ececec; border-radius:0; font-size:13px; }
|
||||
.application-body .prep-edit-table .prep-row-notes { margin-top:3px; color:#757677; font-size:11px; font-style:italic; }
|
||||
.application-body .prep-edit-table .percent-input { border:0; }
|
||||
.application-body .prep-edit-table .percent-input i { padding-right:5px; }
|
||||
.application-body .prep-row-remove { display:grid; place-items:center; width:30px; height:30px; padding:0; color:#a5a9c1; background:transparent; border:0; border-radius:50%; cursor:pointer; font-size:20px; }
|
||||
.application-body .prep-row-remove:hover { color:#f63d48; background:#fff1f2; }
|
||||
.application-body .prep-equivalency-link { width:100%; min-height:32px; padding:4px; color:#3d5df6; background:transparent; border:0; border-radius:3px; cursor:pointer; font-size:12px; text-align:left; }
|
||||
.application-body .prep-equivalency-link:hover { background:#f1f5fe; text-decoration:underline; }
|
||||
.application-body .outlined-add-action { min-height:40px; margin-top:20px; padding:8px 16px; color:#202962; background:#fff; border:1px solid #202962; border-radius:22px; cursor:pointer; font-size:14px; font-weight:600; }
|
||||
.application-body #ingredient-done.dirty::after { content:""; display:inline-block; width:6px; height:6px; margin-left:7px; background:#fff; border-radius:50%; vertical-align:middle; }
|
||||
.recipe-tab-panel { display:none; }.recipe-tab-panel.active { display:block; }.recipe-view-details > .recipe-derived-panel { margin-top:0; padding-top:0; border-top:0; }.recipe-view-details .derived-grid { display:block; }.recipe-view-details .derived-card { border:0; padding:0; }.recipe-view-details .derived-title h2 { font:700 1.4rem var(--sans); }
|
||||
.recipe-view-equivalencies h2,.recipe-equivalence-editor h2 { font:700 1.4rem var(--sans); }.recipe-view-equivalencies > div,.recipe-equivalence-editor > div { display:grid; grid-template-columns:1fr auto 1fr; gap:12px; padding:14px 4px; border-bottom:1px solid #e8eaf0; }.recipe-view-equivalencies small,.recipe-equivalence-editor small { grid-column:1/-1; color:#8b93a7; }.recipe-equivalence-editor { margin:30px 38px; padding:24px; border:1px solid #e8eaf0; }.recipe-equivalence-editor > p { color:#8b93a7; }.recipe-equivalence-editor form { display:grid; grid-template-columns:90px 1fr auto 90px 1fr minmax(150px,1fr) auto; align-items:center; gap:8px; margin-top:20px; }.recipe-equivalence-editor input,.recipe-equivalence-editor select { min-width:0; padding:9px; border:1px solid #dfe3ec; }.recipe-equivalence-editor button { padding:10px 12px; color:white; background:#4868f7; border:0; border-radius:5px; font-weight:700; }
|
||||
.editable-entity-title {
|
||||
display:block;
|
||||
width:min(680px,50vw);
|
||||
margin:0;
|
||||
padding:2px 0 4px;
|
||||
color:#050841;
|
||||
background:transparent;
|
||||
border:0;
|
||||
border-bottom:1px solid transparent;
|
||||
border-radius:0;
|
||||
font-family:var(--fmt-font-sans);
|
||||
font-size:28px;
|
||||
font-weight:900;
|
||||
line-height:42px;
|
||||
letter-spacing:0;
|
||||
}
|
||||
.editable-entity-title:hover,
|
||||
.editable-entity-title:focus {
|
||||
border-bottom-color:#3d5df6;
|
||||
outline:0;
|
||||
background:transparent;
|
||||
}
|
||||
#recipe-done {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
min-height:32px;
|
||||
padding:6px 18px;
|
||||
color:#fff;
|
||||
background:#3d5df6;
|
||||
border:0;
|
||||
border-radius:100px;
|
||||
font-family:var(--fmt-font-sans);
|
||||
font-size:15px;
|
||||
font-weight:500;
|
||||
cursor:pointer;
|
||||
box-shadow:none;
|
||||
}
|
||||
#recipe-done:hover {
|
||||
background:#2f4de0;
|
||||
}
|
||||
.calculate-toggle { display:inline-flex; align-items:center; gap:9px; color:#26346d; font-size:.82rem; font-weight:700; white-space:nowrap; }.calculate-toggle input { position:absolute; opacity:0; pointer-events:none; }.calculate-toggle i { position:relative; width:35px; height:20px; background:#cbd1dc; border-radius:12px; }.calculate-toggle i::after { content:""; position:absolute; top:3px; left:3px; width:14px; height:14px; background:#fff; border-radius:50%; transition:.15s; }.calculate-toggle input:checked + i { background:#43b99f; }.calculate-toggle input:checked + i::after { transform:translateX(15px); }.calculator-heading .calculate-toggle { grid-column:1/-1; justify-self:end; }.structure-heading-actions { display:flex; align-items:center; gap:18px; }.recipe-edit-workspace { display:grid; grid-template-columns:52% 48%; border-top:1px solid #e8eaf0; }.recipe-edit-structure { min-width:0; padding:0 32px 48px 38px; border-right:1px solid #e8eaf0; }.recipe-edit-structure .structure-editor { display:block; }.recipe-edit-structure .section-heading { min-height:76px; margin:0; padding:18px 0 12px; }.recipe-edit-structure .component-editor { margin-right:0; background:#fff; border-color:#e8eaf0; }.recipe-edit-static-method { min-width:0; padding:28px 30px 60px; }.recipe-edit-structure .editor-items { min-width:54rem; }.recipe-edit-structure .editor-items th:nth-child(2),.recipe-edit-structure .editor-items td:nth-child(2) { width:6rem; }.recipe-edit-structure .editor-items th:nth-child(3),.recipe-edit-structure .editor-items td:nth-child(3) { width:7rem; text-align:right; }.recipe-edit-structure .editor-items th:nth-child(4),.recipe-edit-structure .editor-items td:nth-child(4) { width:11rem; }.recipe-edit-structure .editor-items th:nth-child(5),.recipe-edit-structure .editor-items td:nth-child(5) { width:9rem; }.recipe-edit-structure .editor-items th:nth-child(6),.recipe-edit-structure .editor-items td:nth-child(6) { width:6rem; }.recipe-edit-structure .editor-items th:last-child,.recipe-edit-structure .editor-items td:last-child { width:8rem; }.recipe-edit-static-method h2 { margin-bottom:18px; }
|
||||
.percent-mode { display:inline-flex; border:1px solid #dfe3ec; border-radius:18px; overflow:hidden; }.percent-mode button { min-width:96px; padding:7px 11px; color:#26346d; background:#fff; border:0; cursor:pointer; font-weight:700; }.percent-mode button.active { background:#e9efff; }.calculator-percent-controls { grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; gap:14px; }.calculator-percent-controls .calculate-toggle { justify-self:auto; }.basis-choice { display:inline-flex; align-items:center; gap:4px; margin-left:10px; color:#4868f7; font-size:.68rem; font-weight:700; }.basis-choice input { width:auto; }.structure-heading-actions { flex-wrap:wrap; justify-content:flex-end; }
|
||||
.editor-items .base-column { width:4.5rem; text-align:center; }.editor-items .base-column input { width:17px; height:17px; margin:0; accent-color:#4868f7; }.bakers-base-summary { display:grid; grid-template-columns:55px 1fr; gap:14px; margin:0 0 28px; padding:18px; background:#f7f7f8; border-radius:5px; }.bakers-base-summary > strong { color:#a0a7ba; font-weight:500; }.bakers-base-summary p { display:grid; grid-template-columns:80px 1fr; gap:10px; margin:0 0 10px; }.bakers-base-summary p:last-child { margin-bottom:0; }.bakers-base-summary a { color:#111a49; text-decoration:none; font-weight:700; }.bakers-base-summary span { color:#26346d; }
|
||||
.bakers-base-summary p > span { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; border-bottom:1px solid #a5a9c1; }
|
||||
.bakers-base-summary input,.bakers-base-summary select { min-width:0; padding:3px 2px; color:#26346d; background:transparent; border:0; font:inherit; }
|
||||
.bakers-base-summary input { width:100%; text-align:right; }
|
||||
.bakers-base-summary select { max-width:48px; color:#3d5df6; }
|
||||
.entity-directory-table { background:#fff; border:1px solid #eef0f5; }.entity-directory-toolbar { display:grid; grid-template-columns:36px 64px minmax(12rem,1fr) auto; align-items:center; gap:8px; min-height:58px; padding:0 20px; border-bottom:1px solid #eef0f5; }.entity-directory-toolbar input,.entity-directory-row > input { width:20px; height:20px; margin:0; accent-color:#3d5df6; }.entity-directory-toolbar strong { color:#5366fa; font-size:.88rem; }.entity-directory-toolbar button { padding:7px 10px; color:#64748b; background:transparent; border:0; cursor:pointer; }.entity-directory-toolbar .bulk-delete { color:#a73838; }.directory-error { margin:0; padding:11px 20px; color:#8d2e2e; background:#fff1f1; border-bottom:1px solid #f5cece; }.entity-directory-head,.entity-directory-row { display:grid; align-items:center; gap:8px; padding:0 20px; }.entity-directory-head { grid-template-columns:36px 64px minmax(14rem,1fr) repeat(var(--directory-columns),minmax(7rem,10rem)) 28px; min-height:38px; color:#8b93a7; border-bottom:1px solid #eef0f5; font-size:.7rem; text-transform:uppercase; }.entity-directory-head > span:first-child { grid-column:auto; }.entity-directory-row { grid-template-columns:36px 64px minmax(14rem,1fr) repeat(var(--directory-columns),minmax(7rem,10rem)) 28px; min-height:62px; border-bottom:1px solid #eef0f5; }.entity-directory-row:last-child { border-bottom:0; }.entity-directory-row.selected { background:#edf2ff; }.entity-directory-head.columns-1,.entity-directory-row.columns-1 { --directory-columns:1; }.entity-directory-head.columns-2,.entity-directory-row.columns-2 { --directory-columns:2; }.entity-directory-head.columns-3,.entity-directory-row.columns-3 { --directory-columns:3; }.entity-directory-name { display:flex; flex-direction:column; min-width:0; }.entity-directory-name a { overflow:hidden; color:#111a49; text-decoration:none; text-overflow:ellipsis; white-space:nowrap; }.entity-directory-name small { color:#8b93a7; }.directory-attention { display:flex; gap:5px; }.directory-attention em { padding:2px 5px; color:#9a3412; background:#fff0e8; border-radius:3px; font-size:.62rem; font-style:normal; font-weight:700; text-transform:uppercase; }.entity-directory-value { overflow:hidden; color:#687086; font-size:.82rem; text-overflow:ellipsis; white-space:nowrap; }.entity-row-actions { position:relative; }.entity-row-actions > summary { color:#a0a7ba; cursor:pointer; font-size:1.4rem; list-style:none; text-align:center; }.entity-row-actions > summary::-webkit-details-marker { display:none; }.entity-row-actions > div { position:absolute; z-index:5; top:28px; right:0; width:130px; padding:5px; background:#fff; border:1px solid #dfe3ec; border-radius:5px; box-shadow:0 8px 22px rgba(20,30,60,.14); }.entity-row-actions a,.entity-row-actions button { display:block; width:100%; padding:8px 10px; color:#26346d; background:transparent; border:0; text-align:left; text-decoration:none; cursor:pointer; }.entity-row-actions button { color:#a73838; }.entity-row-actions a:hover,.entity-row-actions button:hover { background:#f7f8fc; }
|
||||
.delete-confirmation { width:min(700px,calc(100% - 32px)); padding:30px 38px; color:#050841; background:#fff; border:0; border-radius:10px; box-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); }.delete-confirmation::backdrop { background:rgba(0,0,0,0.5); }.delete-confirmation h2 { margin:0 30px 12px 0; color:#202962; font:700 28px/39px var(--sans); }.delete-confirmation p { margin:0 0 30px; color:#050841; font-size:15px; font-weight:400; line-height:1.5; }.delete-confirmation > div { display:flex; justify-content:flex-end; align-items:center; gap:52px; }.delete-confirmation button { padding:0; background:transparent; border:0; cursor:pointer; color:#050841; font-size:15px; font-weight:500; }.delete-confirmation .confirm-delete { min-width:250px; padding:16px 24px; color:#fff; background:#f63d48; border-radius:100px; font-size:16px; font-weight:500; line-height:16px; }.delete-confirmation .dialog-close { position:absolute; top:18px; right:18px; padding:4px; color:#a5a9c1; border:0; font-size:24px; }
|
||||
.entity-directory-head,.entity-directory-row,.entity-directory-head.columns-1,.entity-directory-head.columns-2,.entity-directory-head.columns-3,.entity-directory-row.columns-1,.entity-directory-row.columns-2,.entity-directory-row.columns-3 { grid-template-columns:36px 64px minmax(14rem,1fr) 28px; }.entity-directory-head > span:first-child { grid-column:auto; }.entity-directory-head > span:nth-child(2) { text-align:left; }.entity-directory-name { justify-content:center; }.entity-directory-toolbar { min-height:52px; }
|
||||
␍
|
||||
+1327
-1294
File diff suppressed because it is too large.
Load diff
@@ -1,84 +1,82 @@
|
||||
/* Purchasing Review Tool */
|
||||
.application-body .purchasing-review-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
|
||||
.application-body .purchasing-review-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .purchasing-review-header { margin-bottom: 24px; }
|
||||
.application-body .purchasing-breadcrumbs { margin-bottom: 8px; }
|
||||
.application-body .purchasing-breadcrumbs a { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 14px; font-weight: 500; text-decoration: none; }
|
||||
.application-body .purchasing-review-header h1 { margin: 0 0 6px; color: #050841; font-size: 28px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.application-body .purchasing-subtitle { color: #8b93a7; font-size: 14px; margin: 0; }
|
||||
.application-body .purchasing-review-toolbar { position: sticky; top: 52px; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; margin-bottom: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 8px; box-shadow: 0 2px 8px rgba(5,8,65,0.06); }
|
||||
.application-body .purchasing-review-stats { display: flex; flex-direction: column; gap: 2px; }
|
||||
.application-body .stats-counter { display: flex; align-items: center; gap: 6px; }
|
||||
.application-body .stats-count { color: #3d5df6; font-size: 16px; font-weight: 700; }
|
||||
.application-body .stats-label { color: #050841; font-size: 14px; font-weight: 500; }
|
||||
.application-body .stats-hint { color: #8b93a7; font-size: 12px; }
|
||||
.application-body .purchasing-review-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.application-body .purchasing-search-box { display: flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 12px; background: #ffffff; border: 1px solid #dfe3ec; border-radius: 4px; }
|
||||
.application-body .purchasing-search-box .search-icon { color: #8283a0; flex: none; }
|
||||
.application-body .purchasing-search-box input { border: 0; background: transparent; outline: none; font-family: var(--fmt-font-sans); font-size: 14px; color: #050841; min-width: 180px; }
|
||||
.application-body .search-clear-btn { background: transparent; border: 0; color: #a5a9c1; font-size: 18px; cursor: pointer; padding: 0 2px; }
|
||||
.application-body .purchasing-filter-toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
|
||||
.application-body .purchasing-filter-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.application-body .toggle-track { display: inline-block; width: 34px; height: 18px; background: #cbd1dc; border-radius: 12px; position: relative; transition: background 0.15s ease; }
|
||||
.application-body .toggle-thumb { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: #fff; border-radius: 50%; transition: transform 0.15s ease; }
|
||||
.application-body .purchasing-filter-toggle input:checked + .toggle-track { background: #3d5df6; }
|
||||
.application-body .purchasing-filter-toggle input:checked + .toggle-track .toggle-thumb { transform: translateX(16px); }
|
||||
.application-body .toggle-label { color: #050841; font-size: 13px; font-weight: 500; }
|
||||
.application-body .purchasing-export-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 16px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-family: var(--fmt-font-sans); font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .purchasing-export-btn:hover { background: #2b4be0; }
|
||||
.application-body .purchasing-products-grid { display: flex; flex-direction: column; gap: 20px; }
|
||||
.application-body .purchasing-card { padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: border-color 0.15s ease; }
|
||||
.application-body .purchasing-card.resolved { border-left: 3px solid #40b49a; }
|
||||
.application-body .purchasing-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #edf0f5; }
|
||||
.application-body .purchasing-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
|
||||
.application-body .supplier-badge { display: inline-flex; padding: 2px 8px; background: #eef2ff; border-radius: 4px; color: #3d5df6; font-size: 12px; font-weight: 600; text-transform: capitalize; }
|
||||
.application-body .supplier-badge.walmart { background: #e6f1fc; color: #0071dc; }
|
||||
.application-body .supplier-badge.sams_club { background: #e1f5fe; color: #0067a0; }
|
||||
.application-body .sku-badge, .application-body .package-badge { display: inline-flex; padding: 2px 8px; background: #f7f7f8; border: 1px solid #ececec; border-radius: 4px; color: #687086; font-size: 12px; font-weight: 500; }
|
||||
.application-body .price-badge { display: inline-flex; padding: 2px 8px; background: #e8f5e9; border-radius: 4px; color: #2e7d32; font-size: 12px; font-weight: 700; }
|
||||
.application-body .purchasing-product-name { margin: 0; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .purchasing-external-link { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; }
|
||||
.application-body .purchasing-external-link:hover { text-decoration: underline; }
|
||||
.application-body .candidates-heading { display: block; margin-bottom: 12px; color: #687086; font-size: 13px; font-weight: 600; }
|
||||
.application-body .candidate-options-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.application-body .candidate-option-card { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 10px 16px; background: #fff; border: 1px solid #dfe3ec; border-radius: 6px; cursor: pointer; transition: all 0.12s ease; }
|
||||
.application-body .candidate-option-card:hover { background: #f7f9fd; border-color: #bcc6e5; }
|
||||
.application-body .candidate-option-card.selected { background: #f1f5fe; border-color: #3d5df6; box-shadow: 0 0 0 1px #3d5df6; }
|
||||
.application-body .candidate-radio { display: none; }
|
||||
.application-body .candidate-custom-radio { display: grid; place-items: center; width: 18px; height: 18px; border: 2px solid #a5a9c1; border-radius: 50%; flex: none; transition: all 0.12s ease; }
|
||||
.application-body .candidate-custom-radio i { width: 8px; height: 8px; background: transparent; border-radius: 50%; transition: background 0.12s ease; }
|
||||
.application-body .candidate-option-card.selected .candidate-custom-radio { border-color: #3d5df6; }
|
||||
.application-body .candidate-option-card.selected .candidate-custom-radio i { background: #3d5df6; }
|
||||
.application-body .candidate-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.application-body .candidate-name { color: #050841; font-size: 15px; font-weight: 500; }
|
||||
.application-body .candidate-meta { color: #8b93a7; font-size: 12px; }
|
||||
.application-body .candidate-score-badge { display: inline-flex; padding: 2px 8px; background: #f1f5fe; border-radius: 12px; color: #3d5df6; font-size: 12px; font-weight: 600; }
|
||||
.application-body .candidate-score-badge.high { background: #e8f5e9; color: #2e7d32; }
|
||||
.application-body .candidate-option-card.none { border-style: dashed; }
|
||||
.application-body .candidate-other-option { margin: 4px 0; padding: 6px 0; }
|
||||
.application-body .other-select-label { display: flex; align-items: center; gap: 12px; }
|
||||
.application-body .other-select-text { color: #687086; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
||||
.application-body .purchasing-select { flex: 1; min-height: 38px; padding: 6px 12px; background: #fff; border: 1px solid #dfe3ec; border-radius: 4px; color: #050841; font-family: var(--fmt-font-sans); font-size: 14px; outline: none; cursor: pointer; }
|
||||
.application-body .purchasing-select:focus { border-color: #3d5df6; }
|
||||
.application-body .purchasing-notice-card { display: flex; align-items: flex-start; gap: 14px; padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; color: #050841; }
|
||||
.application-body .purchasing-notice-card svg { color: #3d5df6; flex: none; margin-top: 2px; }
|
||||
.application-body .purchasing-notice-card strong { display: block; margin-bottom: 4px; font-size: 16px; }
|
||||
.application-body .purchasing-notice-card p { margin: 0; color: #8b93a7; font-size: 14px; }
|
||||
.application-body .ingredient-merge { margin:24px 0; padding:16px; border:1px solid #f0d6d6; }
|
||||
.application-body .ingredient-merge summary { color:#a73838; cursor:pointer; font-weight:600; }
|
||||
.application-body .ingredient-merge form { display:flex; gap:8px; }
|
||||
.application-body .ingredient-merge select { flex:1; padding:8px; }
|
||||
.application-body .ingredient-merge button { color:#fff; background:#f63d48; border:0; padding:8px 12px; }
|
||||
.application-body .recipe-additional-view { display:grid; grid-template-columns:minmax(0,360px) minmax(0,1fr); gap:32px; margin:36px 38px; padding-top:30px; border-top:1px solid #eeeef3; }.application-body .recipe-additional-view figure { margin:0; }.application-body .recipe-additional-view img { display:block; width:100%; max-height:300px; object-fit:cover; }.application-body .recipe-additional-view h2 { font-size:20px; }.application-body .recipe-additional-view p { display:grid; grid-template-columns:120px 1fr; padding:10px 0; border-bottom:1px solid #eeeef3; }.application-body .recipe-additional-view ul { color:#202962; }
|
||||
.application-body .recipe-media-form { grid-template-columns:110px 150px 1fr 1fr auto; align-items:end; margin-top:26px; padding-top:20px; border-top:1px solid #eeeef3; }.application-body .recipe-media-form h3 { grid-column:1/-1; margin:0; font-size:16px; }.application-body .step-media { margin:14px 0 0; }.application-body .step-media img,.application-body .step-media video { display:block; width:min(100%,420px); max-height:300px; object-fit:cover; }.application-body .step-media figcaption { margin-top:5px; color:#8b93a7; font-size:12px; }
|
||||
|
||||
/* Responsive application geometry. Keep wide workspaces useful without allowing
|
||||
a two-pane layout to squeeze either pane below its usable width. */
|
||||
.application-body { overflow-x:clip; }
|
||||
.application-body .entity-detail-shell,.application-body .recipe-detail-shell,
|
||||
.application-body .entity-detail-grid,.application-body .entity-primary,.application-body .entity-secondary,
|
||||
.application-body .recipe-view-workspace,.application-body .recipe-view-formula,.application-body .recipe-view-details,
|
||||
.application-body .recipe-edit-workspace,.application-body .recipe-edit-structure,.application-body .recipe-edit-static-method,
|
||||
.application-body .structure-editor,.application-body .component-editor,.application-body .table-wrap { min-width:0; max-width:100%; }
|
||||
.application-body .table-wrap { overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-gutter:stable; }
|
||||
|
||||
.application-body .purchasing-review-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
|
||||
.application-body .purchasing-review-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .purchasing-review-header { margin-bottom: 24px; }
|
||||
.application-body .purchasing-breadcrumbs { margin-bottom: 8px; }
|
||||
.application-body .purchasing-breadcrumbs a { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 14px; font-weight: 500; text-decoration: none; }
|
||||
.application-body .purchasing-review-header h1 { margin: 0 0 6px; color: #050841; font-size: 28px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.application-body .purchasing-subtitle { color: #8b93a7; font-size: 14px; margin: 0; }
|
||||
.application-body .purchasing-review-toolbar { position: sticky; top: 52px; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; margin-bottom: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 8px; box-shadow: 0 2px 8px rgba(5,8,65,0.06); }
|
||||
.application-body .purchasing-review-stats { display: flex; flex-direction: column; gap: 2px; }
|
||||
.application-body .stats-counter { display: flex; align-items: center; gap: 6px; }
|
||||
.application-body .stats-count { color: #3d5df6; font-size: 16px; font-weight: 700; }
|
||||
.application-body .stats-label { color: #050841; font-size: 14px; font-weight: 500; }
|
||||
.application-body .stats-hint { color: #8b93a7; font-size: 12px; }
|
||||
.application-body .purchasing-review-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.application-body .purchasing-search-box { display: flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 12px; background: #ffffff; border: 1px solid #dfe3ec; border-radius: 4px; }
|
||||
.application-body .purchasing-search-box .search-icon { color: #8283a0; flex: none; }
|
||||
.application-body .purchasing-search-box input { border: 0; background: transparent; outline: none; font-family: var(--fmt-font-sans); font-size: 14px; color: #050841; min-width: 180px; }
|
||||
.application-body .search-clear-btn { background: transparent; border: 0; color: #a5a9c1; font-size: 18px; cursor: pointer; padding: 0 2px; }
|
||||
.application-body .purchasing-filter-toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
|
||||
.application-body .purchasing-filter-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.application-body .toggle-track { display: inline-block; width: 34px; height: 18px; background: #cbd1dc; border-radius: 12px; position: relative; transition: background 0.15s ease; }
|
||||
.application-body .toggle-thumb { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: #fff; border-radius: 50%; transition: transform 0.15s ease; }
|
||||
.application-body .purchasing-filter-toggle input:checked + .toggle-track { background: #3d5df6; }
|
||||
.application-body .purchasing-filter-toggle input:checked + .toggle-track .toggle-thumb { transform: translateX(16px); }
|
||||
.application-body .toggle-label { color: #050841; font-size: 13px; font-weight: 500; }
|
||||
.application-body .purchasing-export-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 16px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-family: var(--fmt-font-sans); font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .purchasing-export-btn:hover { background: #2b4be0; }
|
||||
.application-body .purchasing-products-grid { display: flex; flex-direction: column; gap: 20px; }
|
||||
.application-body .purchasing-card { padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: border-color 0.15s ease; }
|
||||
.application-body .purchasing-card.resolved { border-left: 3px solid #40b49a; }
|
||||
.application-body .purchasing-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #edf0f5; }
|
||||
.application-body .purchasing-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
|
||||
.application-body .supplier-badge { display: inline-flex; padding: 2px 8px; background: #eef2ff; border-radius: 4px; color: #3d5df6; font-size: 12px; font-weight: 600; text-transform: capitalize; }
|
||||
.application-body .supplier-badge.walmart { background: #e6f1fc; color: #0071dc; }
|
||||
.application-body .supplier-badge.sams_club { background: #e1f5fe; color: #0067a0; }
|
||||
.application-body .sku-badge, .application-body .package-badge { display: inline-flex; padding: 2px 8px; background: #f7f7f8; border: 1px solid #ececec; border-radius: 4px; color: #687086; font-size: 12px; font-weight: 500; }
|
||||
.application-body .price-badge { display: inline-flex; padding: 2px 8px; background: #e8f5e9; border-radius: 4px; color: #2e7d32; font-size: 12px; font-weight: 700; }
|
||||
.application-body .purchasing-product-name { margin: 0; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .purchasing-external-link { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; }
|
||||
.application-body .purchasing-external-link:hover { text-decoration: underline; }
|
||||
.application-body .candidates-heading { display: block; margin-bottom: 12px; color: #687086; font-size: 13px; font-weight: 600; }
|
||||
.application-body .candidate-options-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.application-body .candidate-option-card { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 10px 16px; background: #fff; border: 1px solid #dfe3ec; border-radius: 6px; cursor: pointer; transition: all 0.12s ease; }
|
||||
.application-body .candidate-option-card:hover { background: #f7f9fd; border-color: #bcc6e5; }
|
||||
.application-body .candidate-option-card.selected { background: #f1f5fe; border-color: #3d5df6; box-shadow: 0 0 0 1px #3d5df6; }
|
||||
.application-body .candidate-radio { display: none; }
|
||||
.application-body .candidate-custom-radio { display: grid; place-items: center; width: 18px; height: 18px; border: 2px solid #a5a9c1; border-radius: 50%; flex: none; transition: all 0.12s ease; }
|
||||
.application-body .candidate-custom-radio i { width: 8px; height: 8px; background: transparent; border-radius: 50%; transition: background 0.12s ease; }
|
||||
.application-body .candidate-option-card.selected .candidate-custom-radio { border-color: #3d5df6; }
|
||||
.application-body .candidate-option-card.selected .candidate-custom-radio i { background: #3d5df6; }
|
||||
.application-body .candidate-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.application-body .candidate-name { color: #050841; font-size: 15px; font-weight: 500; }
|
||||
.application-body .candidate-meta { color: #8b93a7; font-size: 12px; }
|
||||
.application-body .candidate-score-badge { display: inline-flex; padding: 2px 8px; background: #f1f5fe; border-radius: 12px; color: #3d5df6; font-size: 12px; font-weight: 600; }
|
||||
.application-body .candidate-score-badge.high { background: #e8f5e9; color: #2e7d32; }
|
||||
.application-body .candidate-option-card.none { border-style: dashed; }
|
||||
.application-body .candidate-other-option { margin: 4px 0; padding: 6px 0; }
|
||||
.application-body .other-select-label { display: flex; align-items: center; gap: 12px; }
|
||||
.application-body .other-select-text { color: #687086; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
||||
.application-body .purchasing-select { flex: 1; min-height: 38px; padding: 6px 12px; background: #fff; border: 1px solid #dfe3ec; border-radius: 4px; color: #050841; font-family: var(--fmt-font-sans); font-size: 14px; outline: none; cursor: pointer; }
|
||||
.application-body .purchasing-select:focus { border-color: #3d5df6; }
|
||||
.application-body .purchasing-notice-card { display: flex; align-items: flex-start; gap: 14px; padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; color: #050841; }
|
||||
.application-body .purchasing-notice-card svg { color: #3d5df6; flex: none; margin-top: 2px; }
|
||||
.application-body .purchasing-notice-card strong { display: block; margin-bottom: 4px; font-size: 16px; }
|
||||
.application-body .purchasing-notice-card p { margin: 0; color: #8b93a7; font-size: 14px; }
|
||||
.application-body .ingredient-merge { margin:24px 0; padding:16px; border:1px solid #f0d6d6; }
|
||||
.application-body .ingredient-merge summary { color:#a73838; cursor:pointer; font-weight:600; }
|
||||
.application-body .ingredient-merge form { display:flex; gap:8px; }
|
||||
.application-body .ingredient-merge select { flex:1; padding:8px; }
|
||||
.application-body .ingredient-merge button { color:#fff; background:#f63d48; border:0; padding:8px 12px; }
|
||||
.application-body .recipe-additional-view { display:grid; grid-template-columns:minmax(0,360px) minmax(0,1fr); gap:32px; margin:36px 38px; padding-top:30px; border-top:1px solid #eeeef3; }.application-body .recipe-additional-view figure { margin:0; }.application-body .recipe-additional-view img { display:block; width:100%; max-height:300px; object-fit:cover; }.application-body .recipe-additional-view h2 { font-size:20px; }.application-body .recipe-additional-view p { display:grid; grid-template-columns:120px 1fr; padding:10px 0; border-bottom:1px solid #eeeef3; }.application-body .recipe-additional-view ul { color:#202962; }
|
||||
.application-body .recipe-media-form { grid-template-columns:110px 150px 1fr 1fr auto; align-items:end; margin-top:26px; padding-top:20px; border-top:1px solid #eeeef3; }.application-body .recipe-media-form h3 { grid-column:1/-1; margin:0; font-size:16px; }.application-body .step-media { margin:14px 0 0; }.application-body .step-media img,.application-body .step-media video { display:block; width:min(100%,420px); max-height:300px; object-fit:cover; }.application-body .step-media figcaption { margin-top:5px; color:#8b93a7; font-size:12px; }
|
||||
|
||||
.application-body { overflow-x:clip; }
|
||||
.application-body .entity-detail-shell,.application-body .recipe-detail-shell,
|
||||
.application-body .entity-detail-grid,.application-body .entity-primary,.application-body .entity-secondary,
|
||||
.application-body .recipe-view-workspace,.application-body .recipe-view-formula,.application-body .recipe-view-details,
|
||||
.application-body .recipe-edit-workspace,.application-body .recipe-edit-structure,.application-body .recipe-edit-static-method,
|
||||
.application-body .structure-editor,.application-body .component-editor,.application-body .table-wrap { min-width:0; max-width:100%; }
|
||||
.application-body .table-wrap { overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-gutter:stable; }
|
||||
␍
|
||||
@@ -1,73 +1,107 @@
|
||||
/* Recipe Books */
|
||||
.application-body .recipe-book-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
|
||||
.application-body .recipe-book-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .recipe-book-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 28px; }
|
||||
.application-body .recipe-book-breadcrumbs { margin-bottom: 8px; }
|
||||
.application-body .recipe-book-breadcrumbs a { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 14px; font-weight: 500; text-decoration: none; transition: color 0.15s ease; }
|
||||
.application-body .recipe-book-breadcrumbs a:hover { color: #202962; }
|
||||
.application-body .recipe-book-header h1 { margin: 0 0 6px; color: #050841; font-size: 28px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.application-body .recipe-book-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.application-body .recipe-book-count-badge { display: inline-flex; align-items: center; padding: 3px 10px; background: #fff; border: 1px solid #dfe3ec; border-radius: 999px; color: #687086; font-size: 13px; font-weight: 500; }
|
||||
.application-body .recipe-book-count-badge .count-number { color: #3d5df6; font-weight: 700; margin-right: 4px; }
|
||||
.application-body .recipe-book-desc, .application-body .recipe-book-subtitle { color: #8b93a7; font-size: 14px; font-weight: 400; margin: 0; }
|
||||
.application-body .recipe-book-header-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.application-body .book-action-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 16px; color: #3d5df6; background: #fff; border: 1px solid #dfe3ec; border-radius: 100px; font-size: 14px; font-weight: 500; text-decoration: none; box-shadow: 0 1px 2px rgba(5,8,65,0.04); transition: all 0.15s ease; }
|
||||
.application-body .book-action-btn:hover { background: #f7f9fd; border-color: #bcc6e5; }
|
||||
.application-body .book-action-btn.active { color: #687086; }
|
||||
.application-body .book-notice { margin-bottom: 20px; padding: 12px 16px; background: #fff1f1; border: 1px solid #f5cece; border-radius: 6px; color: #8d2e2e; font-size: 14px; }
|
||||
.application-body .book-directory-table { background: #fff; border: 1px solid #eef0f5; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .book-directory-toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 0 20px; background: #ffffff; border-bottom: 1px solid #edf0f5; }
|
||||
.application-body .book-toolbar-title { color: #050841; font-size: 14px; font-weight: 600; }
|
||||
.application-body .book-toolbar-count { color: #8b93a7; font-size: 13px; font-weight: 400; }
|
||||
.application-body .book-recipe-list { display: flex; flex-direction: column; }
|
||||
.application-body .book-recipe-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 36px; align-items: center; gap: 12px; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #edf0f5; transition: background-color 0.12s ease; }
|
||||
.application-body .book-recipe-row:last-child { border-bottom: 0; }
|
||||
.application-body .book-recipe-row:hover { background: #f8faff; }
|
||||
.application-body .book-recipe-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: #3c4679; border-radius: 50%; color: #fff; }
|
||||
.application-body .book-recipe-title { color: #050841; font-size: 15px; font-weight: 500; text-decoration: none; transition: color 0.12s ease; }
|
||||
.application-body .book-recipe-title:hover { color: #3d5df6; }
|
||||
.application-body .book-recipe-arrow { display: grid; place-items: center; width: 28px; height: 28px; color: #a5a9c1; border-radius: 50%; text-decoration: none; transition: all 0.12s ease; }
|
||||
.application-body .book-recipe-row:hover .book-recipe-arrow { color: #3d5df6; background: #f1f5fe; }
|
||||
.application-body .book-empty-state, .application-body .archive-empty-state, .application-body .purchasing-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; text-align: center; }
|
||||
.application-body .book-empty-state .empty-icon { display: grid; place-items: center; width: 54px; height: 54px; margin-bottom: 16px; background: #f1f5fe; border-radius: 50%; color: #3d5df6; font-size: 24px; }
|
||||
.application-body .book-empty-state h3, .application-body .archive-empty-state h3, .application-body .purchasing-empty-state h3 { margin: 0 0 6px; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .book-empty-state p, .application-body .archive-empty-state p, .application-body .purchasing-empty-state p { margin: 0 0 20px; color: #8b93a7; font-size: 14px; max-width: 420px; }
|
||||
.application-body .book-add-recipes-btn, .application-body .archive-back-btn, .application-body .purchasing-reset-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 20px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-size: 14px; font-weight: 500; text-decoration: none; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .book-add-recipes-btn:hover, .application-body .archive-back-btn:hover, .application-body .purchasing-reset-btn:hover { background: #2b4be0; }
|
||||
|
||||
/* Recipe Book Editing */
|
||||
.application-body .book-edit-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.application-body .book-edit-card { padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .book-edit-card h2 { margin: 0 0 16px; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .book-edit-card-header { margin-bottom: 16px; }
|
||||
.application-body .section-subtitle { margin: 4px 0 0; color: #8b93a7; font-size: 14px; }
|
||||
.application-body .book-field-group { display: flex; flex-direction: column; gap: 16px; }
|
||||
.application-body .book-field-group label { display: flex; flex-direction: column; gap: 6px; }
|
||||
.application-body .field-label { color: #050841; font-size: 13px; font-weight: 600; }
|
||||
.application-body .book-input, .application-body .book-textarea { width: 100%; padding: 10px 14px; background: #fff; border: 1px solid #dfe3ec; border-radius: 4px; color: #050841; font-family: var(--fmt-font-sans); font-size: 15px; font-weight: 400; outline: none; box-sizing: border-box; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
|
||||
.application-body .book-input:focus, .application-body .book-textarea:focus { border-color: #3d5df6; box-shadow: 0 0 0 2px rgba(61, 93, 246, 0.15); }
|
||||
.application-body .book-recipe-checklist { display: flex; flex-direction: column; gap: 4px; max-height: 480px; overflow-y: auto; padding-right: 4px; }
|
||||
.application-body .book-checklist-item { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 14px; background: #fff; border: 1px solid #edf0f5; border-radius: 6px; cursor: pointer; transition: background 0.12s ease, border-color 0.12s ease; }
|
||||
.application-body .book-checklist-item:hover { background: #f7f9fd; border-color: #e4e7ed; }
|
||||
.application-body .book-checklist-item:has(.book-checkbox:checked) { background: #f1f5fe; border-color: #cfd5fa; }
|
||||
.application-body .book-checkbox { width: 18px; height: 18px; margin: 0; accent-color: #3d5df6; cursor: pointer; }
|
||||
.application-body .book-checklist-label { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.application-body .book-checklist-label strong { color: #050841; font-size: 14px; font-weight: 500; }
|
||||
.application-body .book-edit-actions { display: flex; align-items: center; gap: 16px; padding: 12px 0; }
|
||||
.application-body .book-save-btn { min-height: 40px; padding: 0 24px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-family: var(--fmt-font-sans); font-size: 15px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .book-save-btn:hover { background: #2b4be0; }
|
||||
.application-body .book-cancel-link { color: #687086; font-size: 14px; font-weight: 500; text-decoration: none; padding: 8px 12px; transition: color 0.12s ease; }
|
||||
.application-body .book-cancel-link:hover { color: #050841; }
|
||||
.application-body .recipe-additional-editor { width:min(800px,calc(100% - 76px)); margin:36px 38px; padding-top:28px; border-top:1px solid #eeeef3; }.application-body .recipe-additional-editor > form { display:grid; gap:14px; }.application-body .recipe-additional-editor label { display:grid; gap:5px; }.application-body .recipe-additional-editor input,.application-body .recipe-additional-editor textarea,.application-body .recipe-additional-editor select { padding:9px; }.application-body .recipe-additional-editor fieldset { display:grid; grid-template-columns:120px 120px 1fr; gap:10px; border:1px solid #eeeef3; }.application-body .recipe-additional-editor button { justify-self:start; padding:9px 14px; color:#fff; background:#3d5df6; border:0; }
|
||||
.application-body .nutrition-ingredient-list { margin-top:16px; border-top:1px solid #eeeef3; }
|
||||
.application-body .nutrition-list-heading { display:flex; justify-content:space-between; gap:20px; padding:14px 4px 10px; color:#202962; }
|
||||
.application-body .nutrition-list-heading small { color:#a5a9c1; }
|
||||
.application-body .nutrition-ingredient-list > a { display:grid; grid-template-columns:24px minmax(0,1fr) auto 14px; align-items:center; gap:10px; min-height:58px; padding:7px 4px; border-bottom:1px solid #eeeef3; text-decoration:none; }
|
||||
.application-body .nutrition-ingredient-list > a > span:nth-child(2) { min-width:0; }
|
||||
.application-body .nutrition-ingredient-list strong,.application-body .nutrition-ingredient-list small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.application-body .nutrition-ingredient-list strong { color:#050841; font-weight:500; }
|
||||
.application-body .nutrition-ingredient-list small { margin-top:2px; color:#a5a9c1; font-size:12px; }
|
||||
.application-body .nutrition-ingredient-list em { color:#8b93a7; font-size:12px; font-style:normal; }
|
||||
.application-body .nutrition-ingredient-list b { color:#a5a9c1; font-size:18px; }
|
||||
.application-body .recipe-derived-panel .nutrition-label,.application-body .recipe-edit-nutrition .nutrition-label { max-width:430px; }
|
||||
␍
|
||||
|
||||
.application-body .recipe-book-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
|
||||
.application-body .recipe-book-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .recipe-book-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 28px; }
|
||||
.application-body .recipe-book-breadcrumbs { margin-bottom: 8px; }
|
||||
.application-body .recipe-book-breadcrumbs a { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 14px; font-weight: 500; text-decoration: none; transition: color 0.15s ease; }
|
||||
.application-body .recipe-book-breadcrumbs a:hover { color: #202962; }
|
||||
.application-body .recipe-book-header h1 { margin: 0 0 6px; color: #050841; font-size: 28px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.application-body .recipe-book-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.application-body .recipe-book-count-badge { display: inline-flex; align-items: center; padding: 3px 10px; background: #fff; border: 1px solid #dfe3ec; border-radius: 999px; color: #687086; font-size: 13px; font-weight: 500; }
|
||||
.application-body .recipe-book-count-badge .count-number { color: #3d5df6; font-weight: 700; margin-right: 4px; }
|
||||
.application-body .recipe-book-desc, .application-body .recipe-book-subtitle { color: #8b93a7; font-size: 14px; font-weight: 400; margin: 0; }
|
||||
.application-body .recipe-book-header-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.application-body .book-action-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 16px; color: #3d5df6; background: #fff; border: 1px solid #dfe3ec; border-radius: 100px; font-size: 14px; font-weight: 500; text-decoration: none; box-shadow: 0 1px 2px rgba(5,8,65,0.04); transition: all 0.15s ease; }
|
||||
.application-body .book-action-btn:hover { background: #f7f9fd; border-color: #bcc6e5; }
|
||||
.application-body .book-action-btn.active { color: #687086; }
|
||||
.application-body .book-notice { margin-bottom: 20px; padding: 12px 16px; background: #fff1f1; border: 1px solid #f5cece; border-radius: 6px; color: #8d2e2e; font-size: 14px; }
|
||||
.application-body .book-directory-table { background: #fff; border: 1px solid #eef0f5; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .book-directory-toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 0 20px; background: #ffffff; border-bottom: 1px solid #edf0f5; }
|
||||
.application-body .book-toolbar-title { color: #050841; font-size: 14px; font-weight: 600; }
|
||||
.application-body .book-toolbar-count { color: #8b93a7; font-size: 13px; font-weight: 400; }
|
||||
.application-body .book-recipe-list { display: flex; flex-direction: column; }
|
||||
.application-body .book-recipe-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 36px; align-items: center; gap: 12px; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #edf0f5; transition: background-color 0.12s ease; }
|
||||
.application-body .book-recipe-row:last-child { border-bottom: 0; }
|
||||
.application-body .book-recipe-row:hover { background: #f8faff; }
|
||||
.application-body .book-recipe-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: #3c4679; border-radius: 50%; color: #fff; }
|
||||
.application-body .book-recipe-title { color: #050841; font-size: 15px; font-weight: 500; text-decoration: none; transition: color 0.12s ease; }
|
||||
.application-body .book-recipe-title:hover { color: #3d5df6; }
|
||||
.application-body .book-recipe-arrow { display: grid; place-items: center; width: 28px; height: 28px; color: #a5a9c1; border-radius: 50%; text-decoration: none; transition: all 0.12s ease; }
|
||||
.application-body .book-recipe-row:hover .book-recipe-arrow { color: #3d5df6; background: #f1f5fe; }
|
||||
.application-body .book-empty-state, .application-body .archive-empty-state, .application-body .purchasing-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; text-align: center; }
|
||||
.application-body .book-empty-state .empty-icon { display: grid; place-items: center; width: 54px; height: 54px; margin-bottom: 16px; background: #f1f5fe; border-radius: 50%; color: #3d5df6; font-size: 24px; }
|
||||
.application-body .book-empty-state h3, .application-body .archive-empty-state h3, .application-body .purchasing-empty-state h3 { margin: 0 0 6px; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .book-empty-state p, .application-body .archive-empty-state p, .application-body .purchasing-empty-state p { margin: 0 0 20px; color: #8b93a7; font-size: 14px; max-width: 420px; }
|
||||
.application-body .book-add-recipes-btn, .application-body .archive-back-btn, .application-body .purchasing-reset-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 20px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-size: 14px; font-weight: 500; text-decoration: none; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .book-add-recipes-btn:hover, .application-body .archive-back-btn:hover, .application-body .purchasing-reset-btn:hover { background: #2b4be0; }
|
||||
|
||||
.application-body .book-edit-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.application-body .book-edit-card { padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .book-edit-card h2 { margin: 0 0 16px; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .book-edit-card-header { margin-bottom: 16px; }
|
||||
.application-body .section-subtitle { margin: 4px 0 0; color: #8b93a7; font-size: 14px; }
|
||||
.application-body .book-field-group { display: flex; flex-direction: column; gap: 16px; }
|
||||
.application-body .book-field-group label { display: flex; flex-direction: column; gap: 6px; }
|
||||
.application-body .field-label { color: #050841; font-size: 13px; font-weight: 600; }
|
||||
.application-body .book-input, .application-body .book-textarea { width: 100%; padding: 10px 14px; background: #fff; border: 1px solid #dfe3ec; border-radius: 4px; color: #050841; font-family: var(--fmt-font-sans); font-size: 15px; font-weight: 400; outline: none; box-sizing: border-box; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
|
||||
.application-body .book-input:focus, .application-body .book-textarea:focus { border-color: #3d5df6; box-shadow: 0 0 0 2px rgba(61, 93, 246, 0.15); }
|
||||
.application-body .book-recipe-checklist { display: flex; flex-direction: column; gap: 4px; max-height: 480px; overflow-y: auto; padding-right: 4px; }
|
||||
.application-body .book-checklist-item { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 14px; background: #fff; border: 1px solid #edf0f5; border-radius: 6px; cursor: pointer; transition: background 0.12s ease, border-color 0.12s ease; }
|
||||
.application-body .book-checklist-item:hover { background: #f7f9fd; border-color: #e4e7ed; }
|
||||
.application-body .book-checklist-item:has(.book-checkbox:checked) { background: #f1f5fe; border-color: #cfd5fa; }
|
||||
.application-body .book-checkbox { width: 18px; height: 18px; margin: 0; accent-color: #3d5df6; cursor: pointer; }
|
||||
.application-body .book-checklist-label { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.application-body .book-checklist-label strong { color: #050841; font-size: 14px; font-weight: 500; }
|
||||
.application-body .book-edit-actions { display: flex; align-items: center; gap: 16px; padding: 12px 0; }
|
||||
.application-body .book-save-btn { min-height: 40px; padding: 0 24px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-family: var(--fmt-font-sans); font-size: 15px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .book-save-btn:hover { background: #2b4be0; }
|
||||
.application-body .book-cancel-link { color: #687086; font-size: 14px; font-weight: 500; text-decoration: none; padding: 8px 12px; transition: color 0.12s ease; }
|
||||
.application-body .book-cancel-link:hover { color: #050841; }
|
||||
.application-body .recipe-additional-editor { width:min(800px,calc(100% - 76px)); margin:36px 38px; padding-top:28px; border-top:1px solid #eeeef3; }.application-body .recipe-additional-editor > form { display:grid; gap:14px; }.application-body .recipe-additional-editor label { display:grid; gap:5px; }.application-body .recipe-additional-editor input,.application-body .recipe-additional-editor textarea,.application-body .recipe-additional-editor select { padding:9px; }.application-body .recipe-additional-editor fieldset { display:grid; grid-template-columns:120px 120px 1fr; gap:10px; border:1px solid #eeeef3; }.application-body .recipe-additional-editor button { justify-self:start; padding:9px 14px; color:#fff; background:#3d5df6; border:0; }
|
||||
.application-body .nutrition-ingredient-list { margin-top:16px; border-top:1px solid #eeeef3; }
|
||||
.application-body .nutrition-list-heading { display:flex; justify-content:space-between; gap:20px; padding:14px 4px 10px; color:#202962; }
|
||||
.application-body .nutrition-list-heading small { color:#a5a9c1; }
|
||||
.application-body .nutrition-ingredient-list > a { display:grid; grid-template-columns:24px minmax(0,1fr) auto 14px; align-items:center; gap:10px; min-height:58px; padding:7px 4px; border-bottom:1px solid #eeeef3; text-decoration:none; }
|
||||
.application-body .nutrition-ingredient-list > a > span:nth-child(2) { min-width:0; }
|
||||
.application-body .nutrition-ingredient-list strong,.application-body .nutrition-ingredient-list small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.application-body .nutrition-ingredient-list strong { color:#050841; font-weight:500; }
|
||||
.application-body .nutrition-ingredient-list small { margin-top:2px; color:#a5a9c1; font-size:12px; }
|
||||
.application-body .nutrition-ingredient-list em { color:#8b93a7; font-size:12px; font-style:normal; }
|
||||
.application-body .nutrition-ingredient-list b { color:#a5a9c1; font-size:18px; }
|
||||
.application-body .nutrition-yield-list { max-width:760px; margin-top:24px; border:0; }
|
||||
.application-body .nutrition-yield-list .nutrition-list-heading { padding:0 0 12px; border-bottom:1px solid #eceef3; }
|
||||
.application-body .nutrition-yield-list .nutrition-list-heading strong { font-size:15px; font-weight:600; }
|
||||
.application-body .nutrition-yield-table { width:100%; }
|
||||
.application-body .nutrition-yield-head,.application-body .nutrition-yield-row { display:grid; grid-template-columns:minmax(112px,1.12fr) minmax(90px,.92fr) minmax(126px,.78fr) 58px; align-items:center; column-gap:8px; }
|
||||
.application-body .nutrition-yield-head { min-height:42px; padding:0 3px; color:#9297a8; font-size:10px; font-weight:500; letter-spacing:.01em; }
|
||||
.application-body .nutrition-yield-head span:nth-child(3) { text-align:center; }
|
||||
.application-body .nutrition-yield-head span:last-child { text-align:right; }
|
||||
.application-body .nutrition-yield-row { min-height:62px; padding:0 3px; border-top:1px solid #f0f1f5; }
|
||||
.application-body .nutrition-yield-name { display:flex; align-items:center; gap:9px; min-width:0; color:#15183f; text-decoration:none; }
|
||||
.application-body .nutrition-yield-name strong { overflow:hidden; font-size:13px; font-weight:600; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.application-body .nutrition-food-icon { display:grid; flex:0 0 22px; place-items:center; width:22px; height:22px; color:#fff; background:#d89a4c; border-radius:50%; font-size:9px; line-height:1; }
|
||||
.application-body .nutrition-food-icon.mapped { background:#4aa994; }
|
||||
.application-body .nutrition-yield-row > small { color:#555a78; font-size:11px; }
|
||||
.application-body .nutrition-yield-value { display:flex; align-items:center; justify-content:center; gap:7px; color:#20244d; }
|
||||
.application-body .nutrition-yield-value button { display:grid; flex:0 0 16px; place-items:center; width:16px; height:22px; padding:0; color:#252955; background:transparent; border:0; cursor:pointer; }
|
||||
.application-body .nutrition-yield-value svg { width:13px; height:13px; fill:currentColor; }
|
||||
.application-body .nutrition-yield-value strong { font-size:12px; font-weight:500; }
|
||||
.application-body .nutrition-yield-form { display:flex; align-items:center; justify-content:center; gap:4px; margin:0; }
|
||||
.application-body .nutrition-yield-form label { display:flex; align-items:center; width:62px; min-height:30px; padding:0 5px; border:1px solid #dfe2e9; background:#fff; }
|
||||
.application-body .nutrition-yield-form input { width:100%; min-width:0; padding:0; border:0; background:transparent; font-size:12px; text-align:right; }
|
||||
.application-body .nutrition-yield-form input:focus { outline:0; }
|
||||
.application-body .nutrition-yield-form label:focus-within { border-color:#3d5df6; box-shadow:0 0 0 2px rgba(61,93,246,.12); }
|
||||
.application-body .nutrition-yield-form span { color:#8b93a7; }
|
||||
.application-body .nutrition-yield-form button { display:grid; place-items:center; width:24px; height:24px; min-height:0; padding:0; border:0; border-radius:50%; font-size:13px; cursor:pointer; }
|
||||
.application-body .nutrition-yield-confirm { color:#fff; background:#4aa994 !important; }
|
||||
.application-body .nutrition-yield-cancel { color:#9297a8; background:#f0f1f5 !important; }
|
||||
.application-body .nutrition-yield-row > em { color:#5e637d; font-size:12px; text-align:right; }
|
||||
@media (max-width:760px) {
|
||||
.application-body .nutrition-yield-head { display:none; }
|
||||
.application-body .nutrition-yield-row { grid-template-columns:minmax(0,1fr) auto; gap:6px 12px; padding:12px 0; }
|
||||
.application-body .nutrition-yield-row > small { grid-column:1; }
|
||||
.application-body .nutrition-yield-form,.application-body .nutrition-yield-value { grid-column:1; justify-content:flex-start; }
|
||||
.application-body .nutrition-yield-row > em { grid-column:2; grid-row:1 / span 2; }
|
||||
}
|
||||
.application-body .recipe-derived-panel .nutrition-label,.application-body .recipe-edit-nutrition .nutrition-label { max-width:430px; }
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1498
-1524
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,4 @@
|
||||
/* ==========================================================================
|
||||
Subheader Tabs Navigation
|
||||
========================================================================== */
|
||||
|
||||
|
||||
.application-body .recipe-workspace-tabs,
|
||||
.application-body .recipe-read-tabs,
|
||||
@@ -74,7 +72,6 @@
|
||||
border-bottom: 2px solid #3d5df6 !important;
|
||||
}
|
||||
|
||||
/* Tab Icon: Fixed dimensions, no layout shift across all states */
|
||||
.application-body .recipe-tab-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -94,7 +91,6 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Responsive adjustment for 13" laptop screens (1280px / 1366px) */
|
||||
@media (max-width: 1366px) and (min-width: 901px) {
|
||||
.application-body .recipe-workspace-tabs button,
|
||||
.application-body .recipe-read-tabs button,
|
||||
|
||||
+636
-639
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,4 @@
|
||||
/**
|
||||
* Formulation - Modular Design System
|
||||
* 100% Pixel-Verified Parity
|
||||
*/
|
||||
|
||||
|
||||
@import "./tokens.css";
|
||||
@import "./base.css";
|
||||
|
||||
+351
-352
@@ -1,353 +1,352 @@
|
||||
@media print {
|
||||
body.application-body * { visibility:hidden !important; }
|
||||
body.application-body .nutrition-label,body.application-body .nutrition-label * { visibility:visible !important; }
|
||||
body.application-body .nutrition-label { position:absolute; top:0; left:0; width:360px; max-width:none !important; border:1px solid #000; }
|
||||
@page { margin:.35in; }
|
||||
}
|
||||
|
||||
.application-body .recipe-overview-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 0 16px 0;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
.application-body .yield-title-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #95969c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.application-body .yield-inputs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.application-body .yield-inputs-row input[name="yield_quantity"] {
|
||||
width: 76px;
|
||||
height: 38px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe3ec;
|
||||
border-radius: 4px;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #050841;
|
||||
text-align: left;
|
||||
}
|
||||
.application-body .yield-inputs-row select[name="yield_unit_id"] {
|
||||
min-width: 60px;
|
||||
height: 38px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe3ec;
|
||||
border-radius: 4px;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #050841;
|
||||
cursor: pointer;
|
||||
}
|
||||
.application-body .inline-auto-yield {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: #050841;
|
||||
}
|
||||
.application-body .inline-auto-yield .toggle-button {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: #cbd1dc;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.application-body .inline-auto-yield .toggle-button i {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.application-body .inline-auto-yield.active .toggle-button { background: #40b49a; }
|
||||
.application-body .inline-auto-yield.active .toggle-button i { transform: translateX(16px); }
|
||||
|
||||
.application-body .unified-recipe-editor { padding: 0; }
|
||||
.application-body .unified-recipe-editor .structure-editor {
|
||||
display: block;
|
||||
border-top: 0;
|
||||
}
|
||||
.application-body .unified-recipe-editor .section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 36px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
.application-body .structure-heading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.application-body .calculate-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #050841;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.application-body .calculate-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.application-body .calculate-toggle i {
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
background: #cbd1dc;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.application-body .calculate-toggle i::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.application-body .calculate-toggle input:checked + i {
|
||||
background: #40b49a;
|
||||
}
|
||||
.application-body .calculate-toggle input:checked + i::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
.application-body .percent-mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #ebedf2;
|
||||
padding: 2px;
|
||||
border-radius: 100px;
|
||||
gap: 2px;
|
||||
}
|
||||
.application-body .percent-mode button {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #4d5481;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 100px;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.application-body .percent-mode button.active {
|
||||
background: #fff;
|
||||
color: #050841;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row,
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row:hover,
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row:focus-within {
|
||||
background: transparent !important;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row td {
|
||||
padding: 18px 0 6px;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
border-radius: 0;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-content:hover,
|
||||
.application-body .unified-recipe-editor .component-header-content:focus-within {
|
||||
background: transparent;
|
||||
border-bottom-color: #3d5df6;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-name-input {
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
color: #050841;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 27px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions .remove-component-btn,
|
||||
.application-body .unified-recipe-editor .component-header-actions .drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: #a5a9c1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions .drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions .remove-component-btn:hover {
|
||||
color: #f63d48;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions .drag-handle:hover {
|
||||
color: #050841;
|
||||
}
|
||||
|
||||
.application-body .unified-recipe-editor .table-wrap {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
border-top: 0;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items th {
|
||||
height: 38px;
|
||||
padding: 0 6px 6px;
|
||||
color: #95969c;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
text-align: left;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items th.quantity-column,
|
||||
.application-body .unified-recipe-editor .editor-items .quantity-column { text-align: left; width: 56px; min-width: 46px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.unit-column,
|
||||
.application-body .unified-recipe-editor .editor-items .unit-column { text-align: left; width: 66px; min-width: 52px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.entity-column,
|
||||
.application-body .unified-recipe-editor .editor-items .entity-column { text-align: left; min-width: 130px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.notes-column,
|
||||
.application-body .unified-recipe-editor .editor-items .notes-column { text-align: left; width: 22%; min-width: 80px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.percent-column,
|
||||
.application-body .unified-recipe-editor .editor-items .percent-column { text-align: left; width: 58px; min-width: 46px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.base-column,
|
||||
.application-body .unified-recipe-editor .editor-items .base-column { text-align: center; width: 40px; min-width: 34px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.actions-column,
|
||||
.application-body .unified-recipe-editor .editor-items .actions-column { width: 54px; min-width: 48px; text-align: right; }
|
||||
|
||||
.application-body .unified-recipe-editor .editor-items tbody tr {
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items tbody tr:hover,
|
||||
.application-body .unified-recipe-editor .editor-items tbody tr:focus-within {
|
||||
background-color: #f1f4fb;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items td {
|
||||
height: 56px;
|
||||
padding: 4px 4px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Recipe read view — formulation layout styles */
|
||||
.application-body .immersive-main { background:#ffffff; }
|
||||
.application-body .recipe-detail-shell.recipe-read-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas: "utility utility" "left right";
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
.application-body .recipe-read-left {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 24px 36px 35px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #edf0f5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.application-body .recipe-read-left .entity-detail-header {
|
||||
padding: 0 0 16px;
|
||||
min-height: auto;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.application-body .recipe-read-left .recipe-overview-strip {
|
||||
padding: 0 0 16px;
|
||||
margin-bottom: 20px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
.application-body .recipe-read-left .recipe-structure-workspace {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.application-body .recipe-read-left .structure-editor {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
.application-body .recipe-read-right {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.application-body .recipe-detail-shell .detail-actions-menu button { width:100% !important; max-width:100% !important; min-height:44px; padding:10px 20px; color:#050841; font-size:15px; font-weight:500; }
|
||||
@media print {
|
||||
body.application-body * { visibility:hidden !important; }
|
||||
body.application-body .nutrition-label,body.application-body .nutrition-label * { visibility:visible !important; }
|
||||
body.application-body .nutrition-label { position:absolute; top:0; left:0; width:360px; max-width:none !important; border:1px solid #000; }
|
||||
@page { margin:.35in; }
|
||||
}
|
||||
|
||||
.application-body .recipe-overview-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 0 16px 0;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
.application-body .yield-title-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #95969c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.application-body .yield-inputs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.application-body .yield-inputs-row input[name="yield_quantity"] {
|
||||
width: 76px;
|
||||
height: 38px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe3ec;
|
||||
border-radius: 4px;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #050841;
|
||||
text-align: left;
|
||||
}
|
||||
.application-body .yield-inputs-row select[name="yield_unit_id"] {
|
||||
min-width: 60px;
|
||||
height: 38px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe3ec;
|
||||
border-radius: 4px;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #050841;
|
||||
cursor: pointer;
|
||||
}
|
||||
.application-body .inline-auto-yield {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: #050841;
|
||||
}
|
||||
.application-body .inline-auto-yield .toggle-button {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: #cbd1dc;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.application-body .inline-auto-yield .toggle-button i {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.application-body .inline-auto-yield.active .toggle-button { background: #40b49a; }
|
||||
.application-body .inline-auto-yield.active .toggle-button i { transform: translateX(16px); }
|
||||
|
||||
.application-body .unified-recipe-editor { padding: 0; }
|
||||
.application-body .unified-recipe-editor .structure-editor {
|
||||
display: block;
|
||||
border-top: 0;
|
||||
}
|
||||
.application-body .unified-recipe-editor .section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 36px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
.application-body .structure-heading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.application-body .calculate-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #050841;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.application-body .calculate-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.application-body .calculate-toggle i {
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
background: #cbd1dc;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.application-body .calculate-toggle i::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.application-body .calculate-toggle input:checked + i {
|
||||
background: #40b49a;
|
||||
}
|
||||
.application-body .calculate-toggle input:checked + i::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
.application-body .percent-mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #ebedf2;
|
||||
padding: 2px;
|
||||
border-radius: 100px;
|
||||
gap: 2px;
|
||||
}
|
||||
.application-body .percent-mode button {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #4d5481;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 100px;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.application-body .percent-mode button.active {
|
||||
background: #fff;
|
||||
color: #050841;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row,
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row:hover,
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row:focus-within {
|
||||
background: transparent !important;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row td {
|
||||
padding: 18px 0 6px;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
border-radius: 0;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-content:hover,
|
||||
.application-body .unified-recipe-editor .component-header-content:focus-within {
|
||||
background: transparent;
|
||||
border-bottom-color: #3d5df6;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-name-input {
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
color: #050841;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 27px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions .remove-component-btn,
|
||||
.application-body .unified-recipe-editor .component-header-actions .drag-handle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: #a5a9c1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions .drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions .remove-component-btn:hover {
|
||||
color: #f63d48;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-actions .drag-handle:hover {
|
||||
color: #050841;
|
||||
}
|
||||
|
||||
.application-body .unified-recipe-editor .table-wrap {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
border-top: 0;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items th {
|
||||
height: 38px;
|
||||
padding: 0 6px 6px;
|
||||
color: #95969c;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
text-align: left;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items th.quantity-column,
|
||||
.application-body .unified-recipe-editor .editor-items .quantity-column { text-align: left; width: 56px; min-width: 46px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.unit-column,
|
||||
.application-body .unified-recipe-editor .editor-items .unit-column { text-align: left; width: 66px; min-width: 52px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.entity-column,
|
||||
.application-body .unified-recipe-editor .editor-items .entity-column { text-align: left; min-width: 130px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.notes-column,
|
||||
.application-body .unified-recipe-editor .editor-items .notes-column { text-align: left; width: 22%; min-width: 80px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.percent-column,
|
||||
.application-body .unified-recipe-editor .editor-items .percent-column { text-align: left; width: 58px; min-width: 46px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.base-column,
|
||||
.application-body .unified-recipe-editor .editor-items .base-column { text-align: center; width: 40px; min-width: 34px; }
|
||||
.application-body .unified-recipe-editor .editor-items th.actions-column,
|
||||
.application-body .unified-recipe-editor .editor-items .actions-column { width: 54px; min-width: 48px; text-align: right; }
|
||||
|
||||
.application-body .unified-recipe-editor .editor-items tbody tr {
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items tbody tr:hover,
|
||||
.application-body .unified-recipe-editor .editor-items tbody tr:focus-within {
|
||||
background-color: #f1f4fb;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items td {
|
||||
height: 56px;
|
||||
padding: 4px 4px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.application-body .immersive-main { background:#ffffff; }
|
||||
.application-body .recipe-detail-shell.recipe-read-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas: "utility utility" "left right";
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
.application-body .recipe-read-left {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 24px 36px 35px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #edf0f5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.application-body .recipe-read-left .entity-detail-header {
|
||||
padding: 0 0 16px;
|
||||
min-height: auto;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.application-body .recipe-read-left .recipe-overview-strip {
|
||||
padding: 0 0 16px;
|
||||
margin-bottom: 20px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
.application-body .recipe-read-left .recipe-structure-workspace {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.application-body .recipe-read-left .structure-editor {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
.application-body .recipe-read-right {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.application-body .recipe-detail-shell .detail-actions-menu button { width:100% !important; max-width:100% !important; min-height:44px; padding:10px 20px; color:#050841; font-size:15px; font-weight:500; }
|
||||
␍
|
||||
+28
-18
@@ -40,7 +40,7 @@
|
||||
display: contents !important;
|
||||
}
|
||||
|
||||
/* Consistent Top Utility Bar on Mobile */
|
||||
|
||||
.application-body .detail-utility {
|
||||
order: 1 !important;
|
||||
position: relative !important;
|
||||
@@ -172,7 +172,13 @@
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* Entity Detail Title Alignment (Aligned to 16px margin) */
|
||||
.application-body .entity-header-actions .detail-actions-menu > div {
|
||||
right: auto;
|
||||
left: 0;
|
||||
width: min(200px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
|
||||
.application-body .entity-detail-header,
|
||||
.application-body .immersive-main .entity-detail-header,
|
||||
.application-body .immersive-main .recipe-detail-shell .entity-detail-header,
|
||||
@@ -232,8 +238,8 @@
|
||||
order: 3 !important;
|
||||
margin-inline: 16px !important;
|
||||
}
|
||||
|
||||
/* Segmented Mobile Tab Bar */
|
||||
|
||||
|
||||
.application-body .recipe-read-tabs,
|
||||
.application-body .entity-tabs,
|
||||
.application-body .recipe-workspace-tabs {
|
||||
@@ -317,7 +323,7 @@
|
||||
color: #050841 !important;
|
||||
}
|
||||
|
||||
/* Mobile Content Panes */
|
||||
|
||||
.application-body .recipe-view-formula-pane,
|
||||
.application-body .recipe-view-details-pane,
|
||||
.application-body .ingredient-main-content,
|
||||
@@ -370,7 +376,11 @@
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
/* Edit Mode Mobile & Tablet Enhancements */
|
||||
|
||||
.application-body .recipe-structure-workspace.unified-recipe-editor {
|
||||
padding: 0 16px 24px !important;
|
||||
}
|
||||
|
||||
.application-body .unified-recipe-editor .table-wrap {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
@@ -404,7 +414,7 @@
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
/* Mobile Home Search & Filter Popups */
|
||||
|
||||
.application-body .workspace-search-tools {
|
||||
padding: 14px 16px 10px !important;
|
||||
position: relative !important;
|
||||
@@ -478,7 +488,7 @@
|
||||
height: 20px !important;
|
||||
}
|
||||
|
||||
/* Popups: Search Type Filter, Filter Menu, New Menu (Fixed on Top of Everything) */
|
||||
|
||||
.application-body .search-type-filter form {
|
||||
position: fixed !important;
|
||||
top: 66px !important;
|
||||
@@ -519,7 +529,7 @@
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* Workspace Pills on Mobile: Smooth Horizontal Scrolling */
|
||||
|
||||
.application-body .directory-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
@@ -582,7 +592,7 @@
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
|
||||
/* Directory Table Mobile Enhancements */
|
||||
|
||||
.application-body .entity-directory-row,
|
||||
.application-body .entity-directory-row.columns-1,
|
||||
.application-body .entity-directory-row.columns-2,
|
||||
@@ -598,7 +608,7 @@
|
||||
font-weight: 500 !important;
|
||||
color: #050841 !important;
|
||||
}
|
||||
/* Bulk Ingredient Dialog Mobile */
|
||||
|
||||
.bulk-ingredient-backdrop { padding: 0 !important; }
|
||||
.bulk-ingredient-dialog { width: 100% !important; max-height: 100vh !important; min-height: 100vh !important; padding: 52px 22px 28px !important; border-radius: 0 !important; }
|
||||
.bulk-ingredient-dialog h2 { font-size: 24px !important; }
|
||||
@@ -607,7 +617,7 @@
|
||||
.bulk-ingredient-dialog footer { gap: 12px !important; }
|
||||
.bulk-ingredient-dialog .bulk-submit { min-width: 0 !important; flex: 1 !important; }
|
||||
|
||||
/* Recipe Books Mobile */
|
||||
|
||||
.application-body .recipe-book-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
@@ -631,7 +641,7 @@
|
||||
padding: 0 14px !important;
|
||||
}
|
||||
|
||||
/* Archive Mobile */
|
||||
|
||||
.application-body .archive-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
@@ -650,7 +660,7 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Purchasing Review Mobile */
|
||||
|
||||
.application-body .purchasing-review-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
@@ -689,7 +699,7 @@
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
/* Cost Ledger Mobile */
|
||||
|
||||
.application-body .cost-ledger-heading {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
@@ -725,7 +735,7 @@
|
||||
margin-top: 24px !important;
|
||||
}
|
||||
|
||||
/* Inventory Workspace Mobile */
|
||||
|
||||
.application-body .inventory-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
@@ -778,7 +788,7 @@
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* Archive Floating Bar Mobile */
|
||||
|
||||
.application-body .archive-floating-bar {
|
||||
width: calc(100% - 32px) !important;
|
||||
max-width: 400px !important;
|
||||
@@ -793,7 +803,7 @@
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
/* Formula Calculator Heading Mobile */
|
||||
|
||||
.application-body .recipe-view-formula .calculator-heading {
|
||||
gap: 14px 20px !important;
|
||||
margin-bottom: 18px !important;
|
||||
|
||||
+10
-12
@@ -1,16 +1,14 @@
|
||||
/**
|
||||
* Formulation Design System Tokens
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Surface Colors */
|
||||
|
||||
--fmt-bg-page: #ffffff;
|
||||
--fmt-bg-card: #ffffff;
|
||||
--fmt-bg-card-alt: #fafbfd;
|
||||
--fmt-bg-card-hover: #f8faff;
|
||||
--fmt-bg-white: #ffffff;
|
||||
--fmt-bg-active-pill: #dbe4ff;
|
||||
|
||||
/* Text & Ink Colors */
|
||||
|
||||
|
||||
--fmt-text-primary: #050841;
|
||||
--fmt-text-secondary: #202962;
|
||||
--fmt-text-muted: #a5a9c1;
|
||||
@@ -19,7 +17,7 @@
|
||||
--fmt-text-body: rgba(0, 0, 0, 0.87);
|
||||
--fmt-text-danger: #f63d48;
|
||||
|
||||
/* Brand, Type Badge & Accent Colors */
|
||||
|
||||
--fmt-blue: #3d5df6;
|
||||
--fmt-blue-hover: #304fdf;
|
||||
--fmt-blue-active: #1236e1;
|
||||
@@ -38,20 +36,20 @@
|
||||
--fmt-danger-tint: #fff0e8;
|
||||
--fmt-danger-dark: #9a3412;
|
||||
|
||||
/* Border Colors */
|
||||
|
||||
--fmt-border-subtle: #f3f3f3;
|
||||
--fmt-border-default: #ececec;
|
||||
--fmt-border-strong: #dfe3ec;
|
||||
--fmt-border-divider: #eeeef3;
|
||||
|
||||
/* Elevation Shadows (Material UI standard elevations) */
|
||||
|
||||
--fmt-shadow-1: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--fmt-shadow-elevation-2: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12);
|
||||
--fmt-shadow-elevation-4: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12);
|
||||
--fmt-shadow-elevation-8: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
|
||||
--fmt-shadow-elevation-24: 0 11px 15px -7px rgba(0,0,0,0.2), 0 24px 38px 3px rgba(0,0,0,0.14), 0 9px 46px 8px rgba(0,0,0,0.12);
|
||||
|
||||
/* Border Radii */
|
||||
|
||||
--fmt-radius-xs: 2px;
|
||||
--fmt-radius-sm: 4px;
|
||||
--fmt-radius-md: 6px;
|
||||
@@ -61,10 +59,10 @@
|
||||
--fmt-radius-chip: 999px;
|
||||
--fmt-radius-circle: 50%;
|
||||
|
||||
/* Transitions */
|
||||
|
||||
--fmt-transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--fmt-transition-normal: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Fonts */
|
||||
|
||||
--fmt-font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
Reference in new issue
Block a user