Phase A foundation. Cobb 2026-04-29: 'go big or go home' on density-table
aggregator — this commit lands the schema + seed data so the aggregator
engine has something to look up against in step 2.
DB:
- migration 010: cauldron_foods (canonical_name PK, density_g_per_ml,
default_unit_class enum mass/volume/count/mixed, common_size_g,
category, usda_fdc_id, source enum)
- migration 011: cauldron_food_mapping (per-household Mealie food_id →
cauldron canonical food_id, used by aggregator + foods-dedupe later)
Seed data:
- scripts/build_foods_seed.py — extractor that walks USDA SR Legacy
foodPortions, derives density g/ml from cup/tbsp/tsp/fl-oz/ml/etc
measurements (handles SR Legacy's quirk of putting unit in 'modifier'
with measureUnit.name='undetermined'), filters out babyfood / branded
/ fast-food / alcoholic-beverage clutter, normalizes names, categorizes
via longest-keyword-wins
- cauldron/data/foods_seed_usda.json — 2,462 foods with density values
derived from USDA. 636KB, ships in the image.
- cauldron/data/README.md — regen instructions + known issues / iteration
plan (next pass: claude-curated cleanup → ~500-800 high-relevance entries
+ count-based foods like egg/onion that USDA doesn't cover)
Loader (cauldron/foods.py):
- load_seed_if_empty(db) called on app startup right after migrate().
Idempotent — won't reload if table is non-empty.
- reload_seed(db) for forced reloads (INSERT IGNORE).
- search_food(db, name) helper for the aggregator + UI.
Categories present in seed:
produce-vegetable: 300, spice: 256, dairy: 207, condiment: 197,
legume: 189, meat: 166, beverage: 153, baking: 129, produce-fruit: 128,
oil-fat: 126, nut-seed: 115, grain: 89, other: 407
The 407 'other' bucket and the verbose USDA names ('mayonnaise, reduced
fat, with olive oil') will get cleaned up via clawdforge in step 3.
For now the aggregator can already do the math against this seed; the
unit-conversion engine is the next commit.
Cobb: 'searching recipes is a bit off. lets make that way way more on
point. need to be the google of recipe searching.'
Architecture:
- New cauldron_recipe_index table mirrors enough of Mealie's recipe shape
to fuzzy-rank locally without round-tripping. Migrations 008+009.
- Refresh on first /recipes load + every 5 minutes + on-demand button.
Single page-200 pull from Mealie covers Cobb's 226 recipes in one trip.
- recipe_index.py — flatten_recipe(), refresh_household_index(),
search_index().
Search algorithm (rapidfuzz):
- Multi-field weighted: name×1.00, tags×0.85, cats×0.80, foods×0.70,
ings×0.55, description×0.45 (max-of wins, not sum, to avoid noise spike)
- Three scorers per field: WRatio (overall), partial_token_set_ratio
(handles 'spag bol' → 'Spaghetti Bolognese'), token_set_ratio
(order-independent)
- Substring-of-query in title bonus +20
- Floor 50 to filter junk
- Top-80 returned
API:
- /api/recipes.json now uses local index for both search and browse
- /recipes route same — first-page server-render from index
- POST /api/index/refresh — manual refresh button (admin-y)
- ?q=... → ranked fuzzy results, paginated
- no q → ordered browse from index, paginated, has_next via lookahead
Performance:
- Local index query: ~5ms for browse
- Search across 226 rows × 6 fields × 3 scorers: ~60ms
- Should feel instant compared to Mealie's network round-trip
Cobb caught it. Mealie's web URL format is:
https://recipes.sulkta.com/g/hayes-house/r/dairy-free-bread-recipe
Was emitting /recipe/<slug> which 404s.
Added _user_group_slug() helper that pulls .group.slug from /api/users/self
(handles dict-or-string-or-fallback shapes for cross-version compat),
threads the proper URL through both:
- /api/recipes/<slug>.json (used by the modal)
- /recipes/<slug> server-rendered detail (used as fallback for direct links)
Falls back to the old /recipe/<slug> path if we can't resolve a group
slug (won't break, will just 404 in Mealie if that ever happens).
Cobb: 'new page resets all filters. bad flow.' Right.
Modal pattern:
- Click any .recipe-card anywhere → fetches /api/recipes/<slug>.json,
opens a full-screen-on-mobile / centered-on-desktop modal
- The underlying recipes page (search query, sort chip, category chip,
scroll position) stays put
- Browser back button closes the modal (history.pushState + popstate
listener)
- /recipes/<slug> still works server-side as fallback for direct links
and shared URLs (no auto-modal-open on page load)
Implementation:
- New endpoint GET /api/recipes/<slug>.json — same data the detail page
used, plus a public_url field for the 'in mealie ↗' deep link
- Modal HTML lives in _base.html so it's available everywhere
- Click delegate at document level catches .recipe-card clicks app-wide;
Mushroom button click bubbles back out (skipped) so pin toggle still
works without opening the modal
- Modal contains: title bar (sticky), scrollable body (recipe meta pills,
description, ingredients ticked list, numbered instructions), sticky
footer with pin button + 'in mealie' link
- Pin button in modal syncs the matching card's picked state on close so
the grid reflects the change without a refetch
Mobile:
- Modal slides up from bottom, takes full viewport-1 of height
- Close button is 40px circle, top-right
- backdrop-blur 6px, body scroll-locked while open
- Tap outside the modal card → close (e.target === backdrop check)
- Esc key closes
- Cmd/Ctrl/Shift/middle-click still opens in new tab (don't intercept)
Desktop:
- Modal centered, max-width 720px, max-height 86vh
- Backdrop has 30px padding so the card has breathing room
The /plan crash:
AttributeError: 'str' object has no attribute 'get'
in sync_user_household — Mealie's user-self response sometimes returns
household as a slug-string (newer versions) instead of a dict.
Fix: defensive — accept dict OR str, also fall back to top-level
householdId / householdSlug fields. Doesn't crash if all are missing.
Mobile + UX pass (Cobb 2026-04-29 — 'two kids screaming at the store'):
- Mushroom pick button bumped 30px → 48px (Apple HIG min tap target),
inner SVG 16 → 24px, kept opacity transition. Stays one-handed-friendly.
- Recipe cards: padding 14px → 18px, min-height 88px, font-size 1.08em
→ 1.2em on the title. Bigger comfortable tap area.
- Recipe grid: 1col by default, 2col at 720px+, 3col at 1100px+ (was
560/900). Phones get single column, the bigger card.
- Search bar: 14px → 16px font (kills iOS auto-zoom on focus), 8px → 10px
padding. Now sticky at top of /recipes with backdrop-blur.
- Pill chip rows for sort + category — horizontally scrollable, hidden
scrollbar, pill rounded shape, active state in purple-deep bg.
- Sort options: newest, recent (last_made), a-z, updated. Default = newest.
- Category chips: pulled live from Mealie's
/api/organizers/categories, top 14 shown. 'all' chip clears filter.
Mealie client: list_recipes() now accepts orderBy + orderDirection +
categories[] + tags[] params. New list_categories() helper.
Server: _sort_to_order() maps our sort keys to Mealie's orderBy. Recipes
+ json endpoints both honor sort + cat query params and pass through to
the AJAX paginator.
DB:
- migration 006 — cauldron_households (mirrors Mealie household), members
- migration 007 — cauldron_meal_plans (per household per week, lock state)
- new helpers: upsert_household, add_household_member, get_user_household_id,
list_household_member_subs, get_or_create_plan, lock_plan,
auto_lock_past_unlocked_plans, household_scoreboard, household_streak,
list_household_pick_slugs, list_household_picks_with_pickers
Backend:
- sync_user_household() — pulls Mealie's /api/users/self, upserts the
household row, ensures membership. Fires on /connect-mealie POST and
lazy on every /me / /picks / /plan / /recipes load.
- current_household_id() helper used by all user-facing routes
- /recipes + /api/recipes.json now mark items.picked=True if ANYONE in the
household has pinned (shared pool, not per-user)
- /picks now renders household-pooled view with attribution (who pinned what)
- /plan replaces stub: shows current week's lock state + lock button +
scoreboard + streak. POST /api/plan/lock locks this week's plan with
reason='user'. Past unlocked weeks auto-lock on read with reason='auto'.
- /list still stub (v0.3 territory)
UI:
- picks.html: each pick shows '🍄 pinned by <name(s)>' attribution row,
unpin button only on your own picks, household_size in lede
- plan.html: NEW. Lock state pill, lock button (open weeks only), scoreboard
table with rank/wins/last_win, streak ribbon ('🔥 abby on a 3-week run')
if streak >= 2
- me.html: shows household name + member count
Locking semantics match Cobb's pick:
- (c) both — user-lock = scoreboard win, auto-lock past calendar = no win.
Auto-lock fires lazily on /plan view (no cron needed for v0.2).
- Picks pool = (a) shared across household.
- Game shape = medium (locked-by + tally + streak counter, room for richer
badges/notifs in v0.4).
Cobb pass: more mobile friendly + infinite scroll + auto-search +
add-to-AI-plan picks list + mushroom vibes.
DB:
- New migration 005 — cauldron_meal_picks (authentik_sub, recipe_slug,
recipe_name, added_at). Per-user wishlist for the v0.3 AI meal-plan
generator. add/remove/list helpers in DB.
Backend:
- GET /api/recipes.json?page=N&q=Q — paginated + searchable, returns
{items, page, total, total_pages, next}. Each item is annotated with
picked: bool from the user's pick set.
- POST /api/picks/<slug> — add (slug + optional name body)
- DEL /api/picks/<slug> — remove
- GET /api/picks.json — list current picks
- GET /picks — picks page (replaces empty stub)
- Mealie.list_recipes() now accepts search=...
Frontend:
- recipes.html rebuilt:
- sticky search bar with 250ms debounce, hits /api/recipes.json?q=
- IntersectionObserver-driven infinite scroll, loads page+1 when the
sentinel comes into view (200px rootMargin, AbortController for
in-flight cancel on new search)
- per-card mushroom toggle button (top-right) — POST/DELETE to picks
with optimistic UI flip + rollback on failure
- picked cards get a left purple-glow stripe + tinted background
- _recipe_card.html partial — first-page server-render shares markup with
JS-rendered subsequent cards (mushroom SVG inline, same shape)
- recipe_detail.html — '🍄 pin for ai plan' button toggles state in place
- picks.html — list of current picks with remove button + v0.3 explainer
- Topbar nav: dropped /home, added /picks
Mushroom vibes:
- Hand-rolled SVG toadstool (purple cap, bone stem, dark spots) used as
the pick toggle icon — it's the gesture itself
- Same mushroom tiled into the body bg pattern at ~5% opacity in the
bottom-right of the 160px sigil tile, alongside the existing pentagram
- Mushroom emoji on the detail page button + picks page nudge
Mobile pass:
- Topbar nav scrolls horizontally on narrow screens, brand-sub hidden
under 720px, larger tap targets on cards, font-size pulled in slightly
- Recipe grid: 1 col <560, 2 col 560-900, 3 col 900+
- Page-head + button + card padding all tightened on small screens
Cobb: 'more gothic somehow.' Pushed in concrete elements:
- Drop cap on h1 page titles (first letter 1.5em in witch-purple with deep
glow; lowercase rest) — illuminated-manuscript feel
- h1 also lowercase + serif italic for the .accent span (blood-wine color)
- Drop cap on recipe-card .rname (first letter in blood-wine, jumps to
glow on hover)
- Panels now have gilt 18px corner brackets (top-left + top-right) via
::before/::after — very thin gothic frame, brightens on hover
- Inset 1px highlight on top edge for stone-and-glaze depth
- Three panel variants: .green (gilt brackets stay gold), .purple, .blood
- Panel h2 prefixed with ✦ in blood color (decorative bullet)
- New CSS vars: --blood (#7a2c4a aged bordeaux), --blood-bright (#a83d62),
--gilt (#8a6e3a aged brass), --gilt-bright (#c0a058)
- Brand mark Cauldron now flanked by ✦ in blood color
- Crumb breadcrumbs prefixed with ❧ (heart-petal) in gilt
- Lede italic with leading curly quote in gilt
- HR is now a custom SVG flourish divider (gilt curlicue with blood center
dot) instead of a plain line
- Top-of-page-head 60px gilt fade rule
- Body bg replaced pentagram tile with a more atmospheric cross-and-vine
glyph plus a subtle full-page corner vignette darkening (NOT cWHO scanlines,
just radial darken)
- Brand letter-spacing bumped to .22em for that ceremonial feel
Cobb feedback: pull back from cWHO terminal-coded look, blend Abby's gothic
witch with sulkta.com polish. 'burn tokens till we nail it.'
Style direction:
- Polished dark base like sulkta.com — soft purple/green radial glows on
near-black, faint witchy pentagram-circle SVG bg pattern at 5% opacity
- NO scanlines, NO CRT vignette overlay (cWHO is too terminal for this)
- Inter for body (sulkta.com), Cinzel SERIF for h1/h2/brand/recipe-card
titles (gothic flourish), JetBrains Mono for code/labels/uppercase chips
- Soft glow shadows (rgba box-shadow) instead of hard cWHO 3px offsets
- Rounded corners 4-6px throughout
- Smooth fade-in animations on .panel and .page-head
- Pills with subtle background tint (sulkta pill style)
- KV labels in mono uppercase purple — kept the gothic occult-tag feel
- Recipe cards lift on hover with purple glow shadow
Templates extracted from server.py to cauldron/templates/:
- _base.html — full layout shell, topbar, scanlines REMOVED, animation
- me.html — uses {extends '_base.html'}
- connect.html — same
- recipes.html — NEW, paginated grid view
- recipe_detail.html — NEW, full recipe with ingredients + instructions
- stub.html — NEW, placeholder for /plan and /list (v0.3)
Routes added:
- GET /recipes — user-tier: list via current_user_mealie()
- GET /recipes/<slug> — user-tier: detail view
- GET /plan, /list — stubs so nav doesn't 404
Server:
- render_template_string → render_template (proper Jinja file lookup)
- Stripped inline _PALETTE_CSS / ME_TEMPLATE / CONNECT_TEMPLATE constants
- Added current_user_mealie() helper to all user-facing routes
Cauldron's container can't resolve 'recipes.sulkta.com' from inside the
sulkta+sulkta-db-net bridges (Lucy's split-horizon doesn't propagate to
container DNS). Symptom: 500 on /connect-mealie POST when validating the
pasted token.
Fix: take the LAN-internal HTTP path direct to mealie. Mealie shares
OpenVPN-rack2's netns, listening on 9000 inside that netns. Both cauldron
and OpenVPN-rack2 are on sulkta-db-net (172.30.1.0/24), so cauldron talks
to 'http://OpenVPN-rack2:9000' via Docker's internal DNS — bypasses
Apache/HTTPS termination on Rackham entirely.
The public URL stays in the UI (so the connect-mealie page deep-link to
mint a token still goes to https://recipes.sulkta.com via the user's
browser, which DOES resolve it).
Also tightened Mealie._get/_put/_post to wrap requests.RequestException
into MealieError so connection failures don't 500 callers.
Cobb redirect: break away from forest-only, go darker. Halloween / witch /
underground / occult feels.
Palette flipped:
Black #050505 deep, #0a0a0e crypt, #110a1a vault
Purple #1a0d24 eggplant, #3d1f5a witch, #5a2d8c violet, #b878ff hex
Green #1a2611 moss, #2a3a1d swamp, #5a8c3a poison, #88c060 toxic, #9bff5a acid
Bone #d8c8a8 main text, #c9b27c warn
Fonts replaced:
Display Cinzel (sharp Roman caps, occult/witch trial vibe)
Body JetBrains Mono (terminal underground)
Brand Nosifer (the dripping-blood Halloween display face — used only
on the .brand tagline as a single bright accent)
Hard edges everywhere: no border-radius (square corners), no smooth
gradients on elements (only on the body bg as deep purple/green vignette
into black), 3px solid drop-shadows on button hover (the brutalist offset),
sharp 1px borders.
Specifically:
- h1 in toxic green (Cinzel 900, uppercase, letter-spaced) with a 0/24px
acid-green outer glow
- h2 in hex purple, Cinzel 700, uppercase, with a 1px witch-purple
underline
- Panel left-border alternates 3px violet → poison-green stripes
- KV labels in hex purple uppercase, occult-tag style
- Buttons rectangular Cinzel uppercase, 3px hard offset shadow on hover
- Brand mark in Nosifer (dripping)
- Code blocks on eggplant bg with hex purple text
- Selection color: violet on bone
Body bg = pitch black (#050505) with two radial vignettes — witch-purple
top-left, swamp-green bottom-right — bleeding into the black like
candlelight under a coven door.
Cobb course-corrected — wants more purple. Palette now:
Greens forest #1f2d1f, panel #2d3a2a, swamp #3a4a35, meadow #6b8e5a/#88a87a
Purples deep #2a1f3a, amethyst #4a2d5e, heather #6b4a8a, light #9b78c4
Cream #f0e6cc text, #ddd4ba lede, #c9b27c warn
Where purples land:
- h1 in heather (was meadow); h2 stays meadow for hierarchy
- Background a subtle linear-gradient toward #251f30 in the lower right
- Panel left-border accent in heather
- Links default to light heather; underline in deep amethyst
- kv dt labels in heather small-caps for the magic-grimoire feel
- Code blocks on deep purple bg
- Form input focus ring in light heather
- New .btn-purple variant for primary purple actions
- HR uses a gradient image-border heather→swamp→fade
- Brand tagline in heather italic small-caps
Greens still own structure (panels, body bg base, secondary headers,
buttons primary action). Purples are the magic.
- New ME_TEMPLATE — palette-locked, shows user identity + Mealie connection
status + connect/disconnect actions + sign out
- /me.json kept for programmatic callers
- Extracted _PALETTE_CSS shared between /me and /connect-mealie templates
(forest #1f2d1f bg, panels #2d3a2a, meadow accents #6b8e5a/#88a87a,
parchment text #f0e6cc/#ddd4ba, Cormorant Garamond serif headers)
- /me also fetches the Mealie /api/users/self for the connected user so
the page can show 'logged in as <username>, admin: yes/no'
- Connect page polished with cancel button + autocomplete=off on the token
input
Strict palette: no purple, no neon. As locked.
LAN-only Flask API that consumes Mealie (source of truth for recipes / plans
/ lists) and clawdforge (centralized claude -p runner) to do AI work.
v0.1 surface:
GET /healthz liveness + clawdforge upstream
GET /api/recipes proxy Mealie recipe list
POST /api/sterilize/preview/<slug> dry-run AI parse, return proposals
POST /api/sterilize/apply/<slug> write parses back to Mealie
Why sterilizer first: Mealie's CRF parser is mediocre and Cobb's hand-typed
recipes have lots of free-form ingredient strings ("about 2 cups cooked
white rice", "a pinch of salt") that don't aggregate cleanly into a
shopping list. We batch all ingredients of one recipe into a single Sonnet
call via clawdforge, get back parallel structured parses, then on apply
link each to Mealie food/unit records (creating missing by name) and PUT
the recipe back. Preview is non-destructive.
No UI in v0.1 — bearer-auth API only. Frontend + Authentik OIDC + Abby's
swamp/meadow/forest palette arrives in v0.2.
Auth: simple shared bearer in env (ADMIN_BEARER) until OIDC lands. LAN-only
deploy means the bearer is the only gate; no public exposure.
Stack: python:3.12-slim + Flask 3 + gunicorn + requests. No DB in v0.1.