Job 3 surfaced the bug — when I set state=cancelled in the DB, the
daemon thread kept running and finalize() at the end overwrote it
with 'done'. User cancellations were getting silently undone.
Two changes:
1. Runners (run_bulk_preview, run_bulk_apply) now check the job's
current state at the top of every iteration via the new lightweight
db.get_sterilize_job_state. If the state has moved to a terminal
value (cancelled, failed, done) externally, the loop returns
without finalizing.
2. db.finalize_sterilize_job now refuses to overwrite a non-running
state — added "AND state IN ('running','applying')" to the WHERE
clause. Belt-and-suspenders for the same problem: even if a runner
races past the state check and limps to its finalize call, the DB
itself won't let the cancellation be replaced.
Net: hitting cancel via the UI button (or a DB update) now actually
stops the runner mid-flight. Polling roundtrip per recipe is one
SELECT — negligible vs the multi-second clawdforge call that
dominates each iteration.
Mealie's group model spans multiple households (Hayes House group has
Redondo + Lake Elsinore — Cobb's family + Bay/mom share recipes
read-only across households). Members can list/read every recipe in
the group, but write access is per-household. Trying to sterilize a
foreign-household recipe returns 403 on the PUT; the food/unit creates
that ran first end up as orphan rows in the user's own household.
Walk path now resolves /api/users/self.householdId once (cached on the
Mealie client), and skips any recipe whose top-level householdId differs.
No proposal row is created; the recipe just counts as "skipped" along-
side already-clean ones.
Apply path adds the same defensive check (covers job 1's existing
proposals from before this fix landed) and translates any remaining
403 to a friendly "skipped: recipe belongs to a different household —
sterilize from that household's account" message instead of dumping
Mealie's raw permission-denied JSON into the UI.
Net: Cobb sterilizes Redondo recipes from his account; Bay or mom
each get their own walk-and-apply scoped to Lake Elsinore when they
sign in. Same architecture works for new households joining Sulkta —
each member's bulk job is automatically scoped to the household they
belong to. Zero cross-pollution.
Job 1's bulk run apply'd 184 recipes and 182 of them failed with the
same error: POST /api/foods -> 400 UNIQUE constraint failed:
ingredient_foods.name, ingredient_foods.group_id. Cause: Mealie's
name_normalized strips punctuation/whitespace/case more aggressively
than our local _build_name_index's plain .lower(), so the cache misses,
the create_food fires blindly, and Mealie's UNIQUE constraint kills
the call. Whole-recipe apply was wrapped in try/except at the bulk
runner so the recipe got marked errored — but applied_at was still
set to NOW(), making the rerun think we'd already tried. We had, but
the recipe's still unparsed.
Two fixes:
1. sterilizer._resolve_food / _resolve_unit replace the inline
create-on-miss block. Order: local cache → Mealie search-endpoint
tie-break → create. On any UNIQUE-flavored 400 from create, fall
back to one more search to adopt whatever Mealie has under the
normalized form. Mealie's search endpoint applies its own
name_normalized internally so we don't have to mirror its rules.
_search_for_match takes "foods" or "units" and looks for an exact
case-insensitive match against name or pluralName, with a fallback
to "trust Mealie's ranker" when there's exactly one hit.
2. db.mark_proposal_applied no longer sets applied_at on error. On
success: applied_at=NOW(), apply_error=NULL. On error: applied_at
stays NULL, apply_error gets the message. list_approved_unapplied_
proposals keys off applied_at IS NULL, so a rerun naturally retries
only the failed recipes.
Net effect: rerun can now successfully apply the 182 failed recipes
without re-walking them, and won't waste calls on the 2 that did go
through.
Mealie's "Parse" button on every recipe is per-recipe; clicking through
226 of them is the toil Cobb explicitly hates. The bulk sterilizer wraps
cauldron's existing per-recipe Sterilizer (clawdforge → Sonnet, parses
free-form ingredient strings into structured form) into a household-wide
job with progress tracking, batch human review, and one-shot apply.
Flow:
1. POST /api/sterilize/bulk-start — creates cauldron_sterilize_jobs row,
spawns a daemon thread that walks every recipe in the user's
household. Recipes whose ingredients all already have food.id are
skipped (no point re-parsing Cobb's manual cleanup). For each
recipe needing work, Sonnet returns a structured proposal that gets
persisted to cauldron_sterilize_proposals.
2. GET /api/sterilize/bulk-status — polled every 2s by the UI for
{state, processed_count, skipped_count, error_count, current_slug}.
3. After the walk completes, state moves to 'review'. UI loads
/api/sterilize/bulk-jobs/<id>/proposals and renders one card per
recipe with a was→becomes diff per ingredient. User toggles
approve/skip per recipe.
4. POST /api/sterilize/bulk-apply/<id> with {approved_slugs: [...]}.
A second daemon thread iterates approved proposals, calls
Sterilizer.apply_recipe (which resolves food/unit IDs in Mealie,
creating any missing rows, then PUT /api/recipes/<slug>).
Job state machine: running → review → applying → done (or 'cancelled' /
'failed' along the way). At app startup, fail_stuck_sterilize_jobs()
recovers any 'running' / 'applying' rows older than 10 min with no
progress — covers the case where the daemon thread's gunicorn worker
died mid-job. New job state lives in DB; thread is just a runner.
Concurrency: the start endpoint blocks if the household already has a
running/applying job. Sterilize calls cost clawdforge time and parallel
jobs would race on Mealie writes, so one-at-a-time per household is the
right ceiling.
UI lives at /sterilize. Linked from /me → "tools → bulk sterilize" since
it's a one-off admin action, not part of the daily flow. Mobile-aware,
diff cards expand on click. Disabled "apply" button when no recipes
are selected; preview-error rows can't be approved.
DB: two new tables (migrations 015+016). cauldron_sterilize_jobs tracks
overall job state with last_progress_at for the stuck-job recovery;
cauldron_sterilize_proposals holds per-recipe JSON proposals with the
approval flag and the final apply_at/apply_error.
Two changes:
1. foods catalog grows organically. Switch the canonical seed from the
noisy USDA dump (2462 rows of "'s, classic chicken noodle soup")
to the Sonnet-curated cut (229 clean rows). search_food() is now
exact + case-insensitive — Mealie's parser already canonicalizes
food names household-side, so cauldron just needs to look them up
verbatim. On miss, the /list view calls forge.fetch_food_info() to
ask Sonnet for {density_g_per_ml, default_unit_class, common_size_g,
category}, persists the row with source='claude', and the household's
actual kitchen catalog builds itself out as Abby uses it.
Killer case verified end-to-end: "2 cups + 50g + 1.25 lb rice"
collapses to a single "2.25 lb rice" line on the shopping list once
rice has a density row.
2. Game system stripped from /plan. Scoreboard panel, streak banner,
"first to lock takes the week" / "🏆 you locked this one in" copy
all gone. award_pick_points calls in /api/plan/generate +
/api/plan/regenerate stopped firing. household_scoreboard /
household_streak DB methods kept as dead code; cauldron_pick_points
table left in place — non-destructive, easy to revive later if
gamification comes back. Goal: get the base flow (pick → plan →
list) working for Abby first, layer features on after.
- migrations 012 + 013: cauldron_meal_plan_slots + cauldron_pick_points
- db: list_plan_slots, save_plan_slots, delete_plan_slots, mark_plan_generated,
clear_plan_generated, award_pick_points, enrich_plan_with_slots; scoreboard
extended with points (sum from pick_points) and weeks_locked alias
- forge.generate_plan: sonnet prompt builds 7-day plan respecting picks,
validates slot count + day uniqueness + slug-in-pool, fills picker_subs
from ground-truth picks (model output is advisory)
- POST /api/plan/generate: race-safe (existing slots → 409 with plan),
lock-aware (locked → 409), idempotent
- POST /api/plan/regenerate: re-roll for the original generator, gated by
ownership + lock; wipes slots + pick_points then re-runs generate
- plan.html: generate CTA + 7 day cards with picker chips + AI reason +
re-roll button (generator-only, pre-lock); scoreboard now shows points + wins
- /list: pulls plan slots, queries Mealie for ingredients, runs aggregator,
renders 48px-tall checkbox shopping list with localStorage state per plan_id
- tests: 13 new tests across forge.generate_plan + /api/plan/generate routes
+ /list view + scoreboard SQL inspection. conftest+_testenv stub
pymysql/oidc/foods at import time so tests run against module-level app
without a live DB. Both pytest and `unittest discover` paths green (27/27).
Defers: bulk sterilizer admin (A1), foods dedupe (A2), Mealie shopping-list-
export (button rendered but disabled). 7-slot count is fixed at the
endpoint (no UI for slot-count selection yet).
Spec: memory/spec-cauldron-v0.3.md
Pure-Python module + 14 unit tests proving the centerpiece works:
test_rice_mixed:
in: [(2 cup, rice), (1.25 lb, rice)]
out: 2.25 lb rice (one line, properly mass+volume combined via density)
test_butter_mixed:
in: [(0.5 cup, butter), (4 oz, butter)]
out: ~227g butter (~8oz / 0.5 lb)
test_three_recipes:
feeds 9 ingredients across 3 recipes through the aggregator;
rice (cup + lb) collapses, garlic (cloves) sums, eggs count, salt as 'pinch'
bucketed as to-taste. All on one shopping list.
Algorithm in cauldron/aggregator.py:
1. Bucket ingredients by canonical food (foods_lookup callable injected — no DB coupling)
2. Within each food, classify each unit (mass / volume / count / vague / unknown)
3. CASE 1: only one unit class present → simple sum, display in canonical store-friendly unit
4. CASE 2: mass + volume (the killer) → use density_g_per_ml to combine to grams
5. CASE 3: count + (mass | volume) → use common_size_g to convert count to grams
6. CASE 4: anything that can't reconcile (no density, mixed unknown) → split into 1 line per class with is_split=True
7. vague (pinch, dash, to taste) → annotate as 'plus to-taste'
8. unknown units → emit verbatim with the original text
Display: store-friendly unit picker:
<30g → grams
<500g → ounces (nearest 0.5)
<2kg → pounds (nearest 0.25)
>2kg → big pounds
The aggregator is dependency-injection-friendly — foods_lookup(name) is
the only external call. Tests pass a stub dict; production will pass
foods.search_food(db, name). Decouples math from data quality.
Tests run via:
python3 -m unittest discover -s tests -v
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.