pantrybase 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ :root{--bg:#faf8f5;--surface:#fff;--surface-2:#f3efe9;--border:#e6e0d6;--text:#2b2420;--muted:#7a6f66;--accent:#c2410c;--accent-soft:#fde8dc;--ok:#3f7d4a;--ok-soft:#e1f0e3;--warn:#b7791f;--warn-soft:#fbf0d9;--bad:#b42318;--bad-soft:#fde4e1;--shadow:0 1px 2px #2b24200f, 0 4px 16px #2b24200f;--radius:12px;--font:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}@media (prefers-color-scheme:dark){:root{--bg:#171412;--surface:#211d1a;--surface-2:#2a2521;--border:#3a332d;--text:#efe9e3;--muted:#a99d93;--accent:#f97316;--accent-soft:#3d2416;--ok:#7cc48a;--ok-soft:#1f3324;--warn:#e0b04a;--warn-soft:#3a2f17;--bad:#f0736a;--bad-soft:#3d1f1c;--shadow:0 1px 2px #0000004d, 0 4px 16px #00000040;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}}*{box-sizing:border-box}html,body{background:var(--bg);color:var(--text);font:15px / 1.5 var(--font);margin:0}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}h1,h2,h3{margin:0 0 .5rem;line-height:1.2}h1{font-size:1.8rem}h2{font-size:1.15rem}h3{text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-size:.8rem}button,input,select{font:inherit;color:inherit}button{border:1px solid var(--border);background:var(--surface);cursor:pointer;border-radius:8px;padding:.35rem .75rem}button:hover{border-color:var(--accent)}button.primary{background:var(--accent);border-color:var(--accent);color:#fff}button.ghost{background:0 0;border-color:#0000}input[type=search],input[type=text],select{border:1px solid var(--border);background:var(--surface);border-radius:10px;width:100%;padding:.55rem .8rem}input:focus,select:focus,button:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.nav{z-index:10;background:color-mix(in srgb, var(--bg) 88%, transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-bottom:1px solid var(--border);align-items:center;gap:1.25rem;padding:.7rem 1.5rem;display:flex;position:sticky;top:0}.brand{color:var(--text);align-items:center;gap:.45rem;font-size:1.1rem;font-weight:700;display:flex}.brand i{background:var(--accent);border-radius:6px;width:22px;height:22px;display:inline-block}.nav a.link{color:var(--muted);font-weight:500}.nav a.link.active{color:var(--text)}.nav .spacer{flex:1}.nav .stat{color:var(--muted);font-size:.85rem}.badge{background:var(--accent);color:#fff;text-align:center;border-radius:99px;min-width:1.4em;margin-left:.25rem;padding:0 .4em;font-size:.72rem;display:inline-block}main{max-width:1400px;margin:0 auto;padding:1.5rem}.layout{grid-template-columns:250px 1fr;align-items:start;gap:1.5rem;display:grid}@media (width<=860px){.layout{grid-template-columns:1fr}}.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem}.stack>*+*{margin-top:1rem}.row{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.muted{color:var(--muted)}.small{font-size:.85rem}.error{color:var(--bad)}.chip{background:var(--surface-2);border:1px solid var(--border);white-space:nowrap;cursor:default;border-radius:99px;align-items:center;gap:.3rem;padding:.1rem .6rem;font-size:.8rem;display:inline-flex}button.chip{cursor:pointer}.chip.on{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}.chip.ok{background:var(--ok-soft);color:var(--ok);border-color:#0000}.chip.warn{background:var(--warn-soft);color:var(--warn);border-color:#0000}.chip.bad{background:var(--bad-soft);color:var(--bad);border-color:#0000}.chip .x{opacity:.6}.facet{margin-bottom:1rem}.facet h3{margin-bottom:.35rem}.facet .chips{flex-wrap:wrap;gap:.3rem;display:flex}.facet .more{font-size:.8rem}.grid{grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:1rem;display:grid}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);color:inherit;flex-direction:column;transition:transform .12s;display:flex;overflow:hidden}.card:hover{text-decoration:none;transform:translateY(-2px)}.card .img{aspect-ratio:16/10;background:var(--surface-2) left center / cover no-repeat}.card .img.none{color:var(--muted);background:linear-gradient(135deg, var(--accent-soft), var(--surface-2));place-items:center;font-size:2.2rem;display:grid}.card .body{flex-direction:column;flex:1;gap:.35rem;padding:.7rem .85rem .85rem;display:flex}.card .title{font-weight:650;line-height:1.25}.card .sub{color:var(--muted);font-size:.82rem}.card .meta{flex-wrap:wrap;gap:.3rem;margin-top:auto;display:flex}.hero{grid-template-columns:minmax(280px,1.1fr) 1fr;gap:1.5rem;display:grid}@media (width<=860px){.hero{grid-template-columns:1fr}}.gallery img.main{border-radius:var(--radius);background:var(--surface-2);width:100%;display:block}.gallery .thumbs{gap:.5rem;margin-top:.5rem;display:flex}.gallery .thumbs img{object-fit:cover;cursor:pointer;opacity:.7;border:2px solid #0000;border-radius:8px;width:84px;height:58px}.gallery .thumbs img.sel{opacity:1;border-color:var(--accent)}.facts{grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:.6rem;display:grid}.fact{background:var(--surface-2);border-radius:10px;padding:.5rem .7rem}.fact b{text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-size:.72rem;font-weight:600;display:block}.cols{grid-template-columns:1fr 1.6fr .9fr;align-items:start;gap:1.5rem;margin-top:1.5rem;display:grid}@media (width<=1100px){.cols{grid-template-columns:1fr 1.5fr}.cols>:last-child{grid-column:1/-1}}@media (width<=760px){.cols{grid-template-columns:1fr}}ul.ings{margin:0;padding:0;list-style:none}ul.ings li{border-bottom:1px dashed var(--border);align-items:baseline;gap:.5rem;padding:.35rem 0;display:flex}ul.ings li .tick{text-align:center;flex:none;width:1.1rem}ul.ings li .prep{color:var(--muted);font-size:.85rem;font-style:italic}ol.steps{margin:0;padding-left:1.2rem}ol.steps li{margin-bottom:.9rem}ol.steps .stitle{font-weight:650}table.nut{border-collapse:collapse;width:100%;font-size:.9rem}table.nut td{border-bottom:1px solid var(--border);padding:.25rem 0}table.nut td:last-child{text-align:right;font-variant-numeric:tabular-nums}.notice{background:var(--warn-soft);color:var(--warn);border-radius:10px;padding:.6rem .8rem;font-size:.9rem}.bar{background:var(--surface-2);border-radius:99px;height:6px;overflow:hidden}.bar>span{background:var(--ok);height:100%;display:block}.list-row{border-bottom:1px solid var(--border);grid-template-columns:1fr 140px;align-items:center;gap:1rem;padding:.6rem 0;display:grid}.drop{cursor:pointer;border:2px dashed var(--border);border-radius:var(--radius);text-align:center;color:var(--muted);background:var(--surface);padding:2rem;transition:border-color .15s,background .15s;display:block}.drop.over{border-color:var(--accent);background:var(--accent-soft);color:var(--accent)}.tabs{gap:.3rem;display:flex}.tabs button.on{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}.thumbgrid{grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:.8rem;display:grid}.thumb{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden}.thumb img{aspect-ratio:3/2;object-fit:cover;background:var(--surface-2);width:100%;display:block}.thumb .tb{flex-direction:column;gap:.3rem;padding:.45rem .6rem;font-size:.8rem;display:flex}.graph-wrap{grid-template-columns:260px 1fr 300px;gap:1rem;height:calc(100vh - 110px);display:grid}@media (width<=1100px){.graph-wrap{grid-template-columns:1fr;height:auto}.graph-canvas{height:70vh}}.graph-canvas{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);position:relative;overflow:hidden}.graph-canvas .sigma{position:absolute;inset:0}.legend{flex-direction:column;gap:.25rem;font-size:.85rem;display:flex}.dot{border-radius:50%;width:10px;height:10px;margin-right:.4rem;display:inline-block}.side{overflow:auto}.empty{text-align:center;color:var(--muted);padding:3rem 1rem}fieldset.bare{border:0;min-width:0;margin:0;padding:0}fieldset.bare button{min-width:2.2rem;font-size:1rem;line-height:1.2}
@@ -0,0 +1 @@
1
+ (function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e,t,n,r,i,a,o,s,c,l,u,d={},f=[],p=/^m(i|n|o|s|text|space)$/,m=Array.isArray,h=f.slice,g=Object.assign;function _(e){e&&e.parentNode&&e.remove()}function v(e,t,n){var r,i,a,o={},s=arguments.length;for(a in t)a==`key`?r=t[a]:a==`ref`&&typeof e!=`function`?i=t[a]:o[a]=t[a];return s>2&&(o.children=s>3?h.call(arguments,2):n),b(e,o,r,i,null)}function y(e,t,n){var r,i,a,o=g({},e.props),s=arguments.length;for(a in t)a==`key`?r=t[a]:a==`ref`&&typeof e.type!=`function`?i=t[a]:o[a]=t[a];return s>2&&(o.children=s>3?h.call(arguments,2):n),b(e.type,o,r===void 0?e.key:r,i===void 0?e.ref:i,null)}function b(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function x(e){return e.children}function S(e,t){this.props=e,this.context=t,this.__g=0}function C(e,t){if(t==null)return e.__?C(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:C(e)}function w(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),w(e)}function T(t){(8&t.__g||!(t.__g|=8)||!n.push(t)||i++)&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(E)}function E(){var t,r,o,s,c,l,u,d,f;try{for(r=1;n.length;)n.length>r&&n.sort(a),t=n.shift(),r=n.length,8&t.__g&&(s=void 0,c=void 0,l=(c=(o=t).__v).__e,u=[],d=[],(f=o.__P)&&((s=g({constructor:void 0},c)).__v=c.__v+1,e.vnode&&e.vnode(s),N(f,s,c,o.__n,f.namespaceURI,32&c.__u?[l]:null,u,l||C(c),32&c.__u,d),s.__v=c.__v,s.__.__k[s.__i]=s,P(u,s,d),c.__=c.__e=null,s.__e!=l&&w(s)))}finally{n.length=i=0}}function ee(e,t,n,r,i,a,o,s,c,l,u){var p,m,h,g,_,v,y=r.__k||f,b=t.length;for(c=D(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||d,h.__i=p,v=N(e,h,m,i,a,o,s,c,l,u),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&F(m.ref,null,h,m),h.ref&&u.push(h.ref,h.__c||g,h)),_||=g,4&h.__u?(c=O(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-7);return n.__e=_,c}function D(e,t,n,r,i){var a,o,s,c,l,u,d,f,p,h,g=n.length,_=g,v=0,y=!1,S=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor==String?o=S[a]=b(null,o):m(o)?o=S[a]=b(x,{children:o}):o.constructor===void 0&&o.__b?o=S[a]=b(o.type,o.props,o.key,o.ref,o.__v):S[a]=o,c=a+v,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=A(o,n,c,_))&&(_--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=2,l==c-1?v--:l==c+1?v++:l!=c&&(l>c?v--:v++,y=!0)):(~l||(i>g?v--:i<g&&v++),typeof o.type!=`function`&&(o.__u|=4))):S[a]=null;if(y){for(u=[],d=[],a=0;a<i;a++)if((o=S[a])&&2&o.__u){for(f=0,p=u.length;f<p;)u[h=f+p>>1]<o.__i?f=h+1:p=h;u[f]=o.__i,d[a]=f+1}for(v=u.length;a--;)d[a]&&(d[a]==v?v--:S[a].__u|=4)}if(_)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=C(s)),ie(s,s));return r}function O(e,t,n,r){var i,a,o;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=O(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=C(e))&&!t.parentNode&&(t=null),o=t;o&&o.nodeType==8;)o=o.nextSibling;for(e.__e!=o&&(!r&&n.moveBefore&&e.__e.parentNode?n.moveBefore(e.__e,t):n.insertBefore(e.__e,t||null)),t=e.__e;(t&&=t.nextSibling)&&t.nodeType==8;);return t}function k(e,t){return t||=[],e!=null&&typeof e!=`boolean`&&(m(e)?e.some(function(e){k(e,t)}):t.push(e)),t}function A(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l&&!(2&l.__u);if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>+!!u){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function j(e,t,n){n??=``,t[0]==`-`?e.setProperty(t,n):e[t]=n}function M(e,t,n,r,i){var a,o;n:if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||j(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||j(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||={})[t]=n,n&&r||(o=l[t]||(l[t]=te(t)),(e.__a||={})[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function te(t){return function(n){if(this.__e){var r=this.__e[t];if(n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function N(t,n,r,i,a,o,s,c,l,u){var d,p,h,v,y,b,w,T,E,D,O,k,A,j,M,te,N,P,F,I,L=n.type;if(n.constructor!==void 0)return null;if(128&r.__u&&(l=32&r.__u,d=r.__c.__z)){if(n.__u|=l,p=o=[],d.nodeType==8)for(h=1,v=d.nextSibling;v;v=v.nextSibling){if(v.nodeType==8){if(v.data.startsWith(`$s`))h++;else if(v.data.startsWith(`/$s`)&&!--h)break}o.push(v)}else o.push(d);c=o[0]}(d=e.__b)&&d(n);n:if(typeof L==`function`){y=s.length;try{if(D=n.props,O=(d=L.prototype)&&d.render,k=(d=L.contextType)&&i[d.__c],A=d?k?k.props.value:d.__:i,r.__c?2&(b=n.__c=r.__c).__g&&(b.__g|=1):(O?n.__c=b=new L(D,A):(n.__c=b=new S(D,A),b.constructor=L,b.render=ae),k&&k.sub(b),b.state||(b.state={}),b.__n=i,b.__g|=8,b.__h=[],b.__k=[]),O&&(b.__s||(b.__s=b.state),L.getDerivedStateFromProps&&(b.__s==b.state&&(b.__s=g({},b.__s)),g(b.__s,L.getDerivedStateFromProps(D,b.__s)))),w=b.props,T=b.state,b.__v=n,r.__c){if(O&&!L.getDerivedStateFromProps&&D!==w&&b.componentWillReceiveProps&&b.componentWillReceiveProps(D,A),n.__v==r.__v&&!(8&b.__g)||!(4&b.__g)&&b.shouldComponentUpdate&&!1===b.shouldComponentUpdate(D,b.__s,A)){n.__v!=r.__v&&(b.props=D,b.state=b.__s,b.__g&=-9),n.__e=r.__e,n.__k=r.__k,n.__k.some(function(e){e&&(e.__=n)}),f.push.apply(b.__h,b.__k),b.__k=[],b.__h.length&&s.push(b),c=C(r);break n}b.componentWillUpdate&&b.componentWillUpdate(D,b.__s,A),O&&b.componentDidUpdate&&b.__h.push(function(){b.componentDidUpdate(w,T,E)})}else O&&!L.getDerivedStateFromProps&&b.componentWillMount&&b.componentWillMount(),O&&b.componentDidMount&&b.__h.push(b.componentDidMount);if(b.context=A,b.props=D,b.__P=t,b.__g&=-5,j=e.__r,M=0,O)b.state=b.__s,b.__g&=-9,j&&j(n),d=b.render(b.props,b.state,b.context),f.push.apply(b.__h,b.__k),b.__k=[];else do b.__g&=-9,j&&j(n),d=b.render(b.props,b.state,b.context),b.state=b.__s;while(8&b.__g&&++M<25);b.state=b.__s,b.getChildContext&&(i=g({},i,b.getChildContext())),O&&r.__c&&b.getSnapshotBeforeUpdate&&(E=b.getSnapshotBeforeUpdate(w,T)),te=d&&d.type===x&&d.key==null?d.props.children:d,D.__P&&(d=c,a=(t=D.__P).namespaceURI,l=o=null,r.props&&r.props.__P!=t&&(r.__k.some(function(e){e&&ie(e,e)}),r.__k=null),c=r.__k?C(r,0):null),c=ee(t,m(te)?te:[te],n,r,i,a,o,s,c,l,u),D.__P&&(n.__e=null,c=d),n.__u&=-161,128&r.__u&&(b.__z=null),p&&p.some(_),b.__h.length&&s.push(b),1&b.__g&&(b.__g&=-4)}catch(t){if(s.length=y,n.__v=null,l||o){if(t.then){if(N=0,n.__u|=l?160:128,o){for(~(F=o.indexOf(c||void 0))||(F=o.length);(I=o[F-1])&&I.nodeType==8;)F--;for(;F<o.length;F++)if(I=o[F]){if(o[F]=null,I.nodeType==8){if(I.data.startsWith(`$s`))N++||(P=I);else if(N&&I.data.startsWith(`/$s`)&&!--N){c=I;break}}else if(!N)break}}if(!P){for(;c&&c.nodeType==8&&c.nextSibling;)c=c.nextSibling;P=c}n.__c.__z||(n.__c.__z=P),n.__e=c}else o&&o.some(_)}else n.__e=r.__e;n.__k||=r.__k||[],t.then||ne(n),e.__e(t,n,r)}}else c=n.__e=re(r.__e,n,r,i,a,o,s,l,u,t);return(d=e.diffed)&&d(n),128&n.__u?void 0:c}function ne(e){e&&(e.__c&&(e.__c.__g|=4),e.__k&&e.__k.some(ne))}function P(t,n,r){for(var i=0;i<r.length;)F(r[i++],r[i++],r[i++]);e.__c&&e.__c(n,t),t.some(function(n){try{t=n.__h,n.__h=[],t.some(function(e){e.call(n)})}catch(t){e.__e(t,n.__v)}})}function re(t,n,r,i,a,o,s,c,l,u){var f,g,v,y,b,x,S,w,T,E=r.props||d,D=n.props,O=n.type;if(O==`svg`?a=`http://www.w3.org/2000/svg`:O==`math`?a=`http://www.w3.org/1998/Math/MathML`:a||=`http://www.w3.org/1999/xhtml`,o){for(f=0;f<o.length;f++)if((b=o[f])&&(O?b.localName==O:b.nodeType==3)){t=b,o[f]=null;break}}if(!t){if(w=u.ownerDocument||document,!O)return w.createTextNode(D);t=w.createElementNS(a,O,D.is&&D),c&&=(e.__m&&e.__m(n,o),!1),o=null}if(O){if(u=O==`template`?t.content:t,o=O==`textarea`&&D.defaultValue!=null?null:o&&h.call(u.childNodes),!c&&o)for(E={},f=0;f<t.attributes.length;f++)E[(b=t.attributes[f]).name]=b.value;for(f in E)b=E[f],f==`dangerouslySetInnerHTML`?v=b:f==`children`||f in D||f==`value`&&`defaultValue`in D||f==`checked`&&`defaultChecked`in D||M(t,f,null,b,a);for(f in T=1&r.__u,D)b=D[f],f==`children`?y=b:f==`dangerouslySetInnerHTML`?g=b:f==`value`?x=b:f==`checked`?S=b:c&&typeof b!=`function`||!(E[f]!==b||T&&b!=null)||M(t,f,b,E[f],a);g?(c||v&&(g.__html==v.__html||g.__html==t.innerHTML)||(t.innerHTML=g.__html),n.__k=[]):(v&&(t.textContent=``),(O==`foreignObject`||a==`http://www.w3.org/1998/Math/MathML`&&p.test(O))&&(a=`http://www.w3.org/1999/xhtml`),ee(u,m(y)?y:[y],n,r,i,a,o,s,o?o[0]:r.__k&&C(r,0),c,l),o&&o.some(_)),c&&O!=`textarea`||(f=`value`,O==`progress`&&x==null?t.removeAttribute(f):x==null||x===t[f]&&(O!=`progress`||x)||M(t,f,x,E[f],a),f=`checked`,S!=null&&S!=t[f]&&M(t,f,S,E[f],a))}else E===D||c&&t.data==D||(t.data=D);return t}function F(t,n,r,i){try{typeof t==`function`?n?n.__x=t(n)||1:(i&&=i.__c||i.__e)&&(n=i.__x)&&(i.__x=null,typeof n==`function`?n():t(null)):t.current=n}catch(t){e.__e(t,r)}}function ie(t,n,r){var i,a;if(e.unmount&&e.unmount(t),!(i=t.ref)||i.current&&i.current!=t.__e||F(i,null,n,t),i=t.__c){if(i.componentWillUnmount)try{i.componentWillUnmount()}catch(t){e.__e(t,n)}i.__P=i.__n=null}if(i=t.__k)for(a=0;a<i.length;a++)i[a]&&ie(i[a],n,typeof t.type!=`function`||r&&!t.props.__P);(i=t.__e)&&(r||_(i),i.__e&&(i.__e=null)),t.__e=t.__c=t.__=null}function ae(e,t,n){return this.constructor(e,n)}function I(t,n){var r,i,a,o;e.__&&e.__(t,n),n.nodeType==9&&(n=n.documentElement),i=(r=t&&32&t.__u)?null:n.__k,n.__k=b(x,{children:[t]}),a=[],o=[],N(n,n.__k,i||d,d,n.namespaceURI,i?null:n.firstChild?h.call(n.childNodes):null,a,i?i.__e:n.firstChild,r,o),P(a,n.__k,o),n.__k.props.children=null}function L(e){function t(e){var n,r;return this.getChildContext||(n=new Set,(r={})[t.__c]=this,this.getChildContext=function(){return r},this.shouldComponentUpdate=function(e){this.props.value!=e.value&&n.forEach(function(e){e.__g|=4,T(e)})},this.sub=function(e){n.add(e);var t=e.componentWillUnmount;e.componentWillUnmount=function(){n.delete(e),t&&t.call(e)}}),e.children}return t.__c=`__cC`+u++,t.__=e,t.Provider=(t.Consumer=function(e,t){return e.children(t)}).contextType=t,t}e={__e:function(e,t,n,r){for(var a,o,s;t=t.__;)if((a=t.__c)&&!(1&a.__g)){a.__g|=4;try{if((o=a.constructor)&&o.getDerivedStateFromError&&(a.setState(o.getDerivedStateFromError(e)),s=8&a.__g),a.componentDidCatch&&(a.componentDidCatch(e,r||{}),s=8&a.__g),s)return void(a.__g|=2)}catch(t){e=t,s=0}}throw i=0,e}},t=0,S.prototype.setState=function(e,t){var n=this.__s;n&&n!=this.state||(n=this.__s=g({},this.state)),typeof e==`function`&&(e=e(g({},n),this.props)),e&&(g(n,e),this.__v&&(t&&this.__k.push(t),T(this)))},S.prototype.forceUpdate=function(e){this.__v&&(this.__g|=4,e&&this.__h.push(e),T(this))},S.prototype.render=x,n=[],i=0,a=function(e,t){return e.__v.__b-t.__v.__b},o=Symbol(),s=/(PointerCapture)$|Capture$/i,c=0,l={},u=0;var R,z,oe,se,ce=Object.is,le=0,ue=[],de=[],B=e,fe=B.__b,pe=B.__r,me=B.diffed,he=B.__c,ge=B.unmount,_e=B.__;function ve(e,t){B.__h&&B.__h(z,e,le||t),le=0;var n=z.__H||(z.__H={__:[],__h:[]});return e>=n.__.length&&n.__.push({}),n.__[e]}function V(e){return le=1,ye(je,e)}function ye(e,t,n){var r=ve(R++,2);if(r.t=e,!r.__c&&(r.__=[n?n(t):je(void 0,t),function(e){var t=r.__N?r.__N[0]:r.__[0],n=r.t(t,e);ce(t,n)||(r.__N=[n,r.__[1]],r.__c.setState({}))}],r.__c=z,!z.__f)){z.__f=!0;var i=z.shouldComponentUpdate;z.shouldComponentUpdate=function(e,t,n){var r=this.__H;if(!r)return!0;var a=!1,o=this.props!=e;if(r.__.some(function(e){e.__N&&(a=!0,ce(e.__[0],e.__N[0])||(o=!0))}),i){var s=i.call(this,e,t,n);return a?s||o:s}return!a||o}}return r.__}function be(e,t){var n=ve(R++,3);!B.__s&&Ae(n.__H,t)&&(n.__P=!0,n.__=e,n.u=t,z.__H.__h.push(n))}function xe(e,t){var n=ve(R++,4);!B.__s&&Ae(n.__H,t)&&(n.__P=!1,n.__=e,n.u=t,z.__h.push(n))}function H(e){return le=5,Se(function(){return{current:e}},[])}function Se(e,t){var n=ve(R++,7);return Ae(n.__H,t)&&(n.__=e(),n.__H=t),n.__}function Ce(e,t){return le=8,Se(function(){return e},t)}function we(e){var t=z.context[e.__c],n=ve(R++,9);return n.c=e,t?(n.__??(n.__=!0,t.sub(z)),t.props.value):e.__}function Te(){var e;do{for(;e=de.shift();)try{U(e)}catch(t){B.__e(t,{__:(e=e.__P)&&e.__v})}for(;e=ue.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(U),t.__h.some(ke),t.__h=[]}catch(n){t.__h=[],B.__e(n,e.__v)}}}while(de.length)}B.__b=function(e){z=null,fe&&fe(e)},B.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),_e&&_e(e,t)},B.__r=function(e){pe&&pe(e),R=0;var t=(z=e.__c).__H;t&&(oe==z?z.__h=[]:(t.__h.some(U),t.__h.some(ke),R=0),t.__h=[],t.__.some(function(e){e.__N&&(e.__=e.__N),e.u=e.__N=void 0})),oe=z},B.diffed=function(e){me&&me(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&Oe(ue.push(t)),t.__H.__.some(function(e){e.u&&(e.__H=e.u)})),oe=z=null},B.__c=function(e,t){t.some(function(e){try{e.__h.some(U),e.__h=e.__h.filter(function(e){return!e.__||ke(e)})}catch(n){t.some(function(e){e.__h&&=[]}),t=[],B.__e(n,e.__v)}}),he&&he(e,t)},B.unmount=function(e){ge&&ge(e);var t,n,r=e.__c;r&&r.__H&&(r.__H.__.some(function(r){try{if(r.__P&&r.__c){if(n===void 0){for(n=e.__;n&&(!n.__c||!n.__c.__P);)n=n.__;n&&=n.__c}r.__P=n,Oe(de.push(r))}else U(r)}catch(e){t=e}}),r.__H=void 0,t&&B.__e(t,r.__v))};var Ee=typeof requestAnimationFrame==`function`;function De(e){var t,n=function(){clearTimeout(r),Ee&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,35);Ee&&(t=requestAnimationFrame(n))}function Oe(e){e!=1&&se==B.requestAnimationFrame||((se=B.requestAnimationFrame)||De)(Te)}function U(e){var t=z,n=e.__c;typeof n==`function`&&(e.__c=void 0,n()),z=t}function ke(e){var t=z;e.__c=e.__(),z=t}function Ae(e,t){return!e||e.length!=t.length||t.some(function(t,n){return!ce(t,e[n])})}function je(e,t){return typeof t==`function`?t(e):t}var W,Me;function Ne(e){return!Me||(typeof Me==`string`?e.startsWith(Me):Me.test(e))}function Pe(e,t){let n=``;if(W=void 0,t&&t.type===`click`){if(t.ctrlKey||t.metaKey||t.altKey||t.shiftKey||t.button!==0)return e;let r=t.composedPath().find(e=>e.nodeName==`A`&&e.href),i=r&&r.getAttribute(`href`);if(!r||r.origin!=location.origin||/^#/.test(i)||!/^(_?self)?$/i.test(r.target)||!Ne(i)||r.download)return e;W=!0,t.preventDefault(),n=r.href.replace(location.origin,``)}else t&&t.url?(W=!t.replace,n=t.url):n=location.pathname+location.search;return W===!0?history.pushState(null,``,n):W===!1&&history.replaceState(null,``,n),n}var Fe=(e,t,n={})=>{e=e.split(`/`).filter(Boolean),t=(t||``).split(`/`).filter(Boolean),n.params||={};for(let r=0,i,a;r<Math.max(e.length,t.length);r++){let[,o,s,c]=(t[r]||``).match(/^(:?)(.*?)([+*?]?)$/);if(i=e[r],o||s!=i){if(!o&&i&&c==`*`){n.rest=`/`+e.slice(r).join(`/`);break}if(!o||!i&&c!=`?`&&c!=`*`)return;if(a=c==`+`||c==`*`,a?i=e.slice(r).map(decodeURIComponent).join(`/`)||void 0:i&&=decodeURIComponent(i),n.params[s]=i,s in n||(n[s]=i),a)break}}return n};function Ie(e){let[t,n]=ye(Pe,e.url||location.pathname+location.search);e.scope&&(Me=e.scope);let r=W===!0,i=Se(()=>{let e=new URL(t,location.origin),i=e.pathname.replace(/\/+$/g,``)||`/`;return{url:t,path:i,query:Object.fromEntries(e.searchParams),route:(e,t)=>n({url:e,replace:t}),back:()=>{history.back()},forward:()=>{history.forward()},wasPush:r}},[t]);return xe(()=>(addEventListener(`click`,n),addEventListener(`popstate`,n),()=>{removeEventListener(`click`,n),removeEventListener(`popstate`,n)}),[]),v(Ie.ctx.Provider,{value:i},e.children)}var Le=Promise.resolve();function Re(e){let[t,n]=ye(e=>e+1,0),{url:r,query:i,wasPush:a,path:o}=Ue();if(!r)throw Error(`preact-iso's <Router> must be used within a <LocationProvider>, see: https://github.com/preactjs/preact-iso#locationprovider`);let{rest:s=o,params:c={}}=we(He),l=H(!1),u=H(o),d=H(1),f=H(),p=H(),m=H(),h=H(!1),g=H();g.current=!1;let _,b,x;k(e.children).some(e=>{if(Fe(s,e.props.path,x={...e.props,path:s,query:i,params:Object.assign({},c),rest:``}))return _=y(e,x);e.props.default&&(b=y(e,x))});let S=_||b,C=f.current&&f.current.__u&ze&&f.current.__u&Be,w=f.current&&f.current.__h,T=Se(()=>{p.current=f.current,f.current=v(He.Provider,{value:x},S);let e=p.current&&p.current.props.children;return!e||!S||S.type!==e.type||S.props.component!==e.props.component?(d.current++,!0):!1},[r,JSON.stringify(x)]);C?(f.current.__u|=ze,f.current.__u|=Be):w&&(f.current.__h=!0);let E=p.current;return p.current=null,this.__c=(t,i)=>{g.current=!0,p.current=E,e.onLoadStart&&e.onLoadStart(r),l.current=!0;let a=d.current;t.then(()=>{a===d.current&&(p.current=null,f.current&&(i.__h&&(f.current.__h=i.__h),i.__u&Be&&(f.current.__u|=Be),i.__u&ze&&(f.current.__u|=ze)),Le.then(n))})},xe(()=>{let t=this.__v&&this.__v.__e;g.current?!h.current&&!m.current&&(m.current=t):(!h.current&&m.current&&(m.current!==t&&m.current.remove(),m.current=null),h.current=!0,u.current!==o&&(a&&scrollTo(0,0),e.onRouteChange&&e.onRouteChange(r),u.current=o),e.onLoadEnd&&l.current&&e.onLoadEnd(r),l.current=!1)},[o,a,t]),T?[v(Ve,{r:f,key:d.current}),v(Ve,{r:p,key:d.current-1})]:v(Ve,{r:f,key:d.current})}var ze=32,Be=128,Ve=({r:e})=>e.current;Re.Provider=Ie,Ie.ctx=L({});var He=L({}),G=e=>v(e.component,e),Ue=()=>we(Ie.ctx),We=()=>we(He),Ge=e.__b;e.__b=e=>{e.type&&e.type._forwarded&&e.ref&&(e.props.ref=e.ref,e.ref=null),Ge&&Ge(e)};function Ke(e){let t,n,r=()=>e().then(e=>n=e&&e.default||e),i=e=>{let[,i]=V(0),a=H(n);if(t||=r(),n!==void 0)return v(n,e);throw a.current||=t.then(()=>i(1)),t};return i.preload=()=>(t||=r(),t),i._forwarded=!0,i}var qe=e.__e;e.__e=(e,t,n)=>{if(e&&e.then){let r=t;for(;r=r.__;)if(r.__c&&r.__c.__c)return t.__e??(t.__c.__z=[n.__e],t.__e=n.__e,t.__k=n.__k),t.__k||=[],r.__c.__c(e,t)}qe&&qe(e,t,n)};var Je=`modulepreload`,Ye=function(e){return`/`+e},Xe={},Ze=function(e){return e.pathname.endsWith(`.css`)},Qe=function(e,t,n){if(t in e)return e[t];let r=n();if(!r){e[t]=void 0;return}let i=r.then(()=>{e[t]=void 0},n=>{throw e[t]=void 0,n});return e[t]=i,i},$e=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e,i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?new URL(import.meta.resolve(e)):new URL(e,import.meta.url)}r=o(t.map(t=>{t=Ye(t,n);let r=s(t),i=Ze(r);return Qe(Xe,r.href,()=>{if(e===void 0){e={all:new Set,styles:new Set};let t=document.getElementsByTagName(`link`);for(let n=t.length-1;n>=0;n--){let r=t[n];e.all.add(r.href),r.rel===`stylesheet`&&e.styles.add(r.href)}}if((i?e.styles:e.all).has(r.href))return;let t=document.createElement(`link`);if(t.rel=i?`stylesheet`:Je,i||(t.as=`script`),t.crossOrigin=``,t.href=r.href,a&&t.setAttribute(`nonce`,a),document.head.appendChild(t),i)return new Promise((e,n)=>{t.addEventListener(`load`,e),t.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${r}`)))})})}).filter(e=>e!==void 0))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})};async function K(e,t,n,r={}){let i={method:e,headers:{...r}};e!==`GET`&&(i.headers[`X-Pantrybase`]=`1`),n instanceof Blob?i.body=n:n!==void 0&&(i.body=JSON.stringify(n),i.headers[`Content-Type`]=`application/json`);let a=await fetch(t,i),o=await a.json().catch(()=>({}));if(!a.ok)throw Error(o.error??`${a.status} ${a.statusText}`);return o}var et=e=>{let t=new URLSearchParams;for(let[n,r]of Object.entries(e))r!=null&&r!==``&&t.set(n,String(r));let n=t.toString();return n?`?${n}`:``},tt=e=>({tags:e.tags&&Object.keys(e.tags).length?JSON.stringify(e.tags):void 0,exclude_allergens:e.exclude_allergens?.length?e.exclude_allergens.join(`,`):void 0,max_total_min:e.max_total_min,needs_review:e.needs_review?`true`:void 0}),q=(e,t,n)=>{let r=new URLSearchParams;t&&r.set(`w`,String(t)),n&&r.set(`v`,String(n));let i=r.toString();return`/api/files/${e.split(`/`).map(encodeURIComponent).join(`/`)}${i?`?${i}`:``}`},J={stats:()=>K(`GET`,`/api/stats`),facets:()=>K(`GET`,`/api/facets`),search:(e,t,n=48)=>K(`GET`,`/api/search${et({q:e,limit:n,...tt(t)})}`),recipe:e=>K(`GET`,`/api/recipes/${encodeURIComponent(e)}`),pantry:()=>K(`GET`,`/api/pantry`),pantryAdd:e=>K(`POST`,`/api/pantry`,{items:e.map(e=>({name:e}))}),pantryRemove:e=>K(`DELETE`,`/api/pantry`,{names:e}),pantryClear:()=>K(`DELETE`,`/api/pantry`,{all:!0}),match:e=>K(`GET`,`/api/pantry/match${et({...tt(e),min_coverage:e.min_coverage,ingredients:e.ingredients?.join(`,`)})}`),ingredients:e=>K(`GET`,`/api/ingredients${et({q:e,limit:12})}`),graph:e=>K(`GET`,`/api/graph${et({categories:e.categories.join(`,`),ingredients:e.ingredients?`1`:`0`,min_degree:e.min_degree,cuisine:e.cuisine,similar:e.similar})}`),inbox:e=>K(`GET`,`/api/inbox${et({status:e,limit:300})}`),upload:e=>K(`POST`,`/api/inbox/upload`,e,{"X-Filename":encodeURIComponent(e.name)}),resolve:(e,t,n)=>K(`POST`,`/api/inbox/resolve`,{ids:e,status:t,reason:n}),rotate:(e,t)=>K(`POST`,`/api/inbox/rotate`,{id:e,degrees:t})},nt=0;Array.isArray;function Y(t,n,r,i,a,o){n||={};var s,c,l=n;if(`ref`in l&&typeof t!=`function`)for(c in l={},n)c==`ref`?s=n[c]:l[c]=n[c];var u={type:t,props:l,key:r,ref:s,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--nt,__i:-1,__u:0};return(a||o)&&(u.__source=a,u.__self=o),e.vnode&&e.vnode(u),u}function X(e,t){let[n,r]=V({loading:!0}),i=H(0),a=Ce(()=>{let t=++i.current;r(e=>({...e,loading:!0})),e().then(e=>t===i.current&&r({data:e,loading:!1}),e=>t===i.current&&r(t=>({...t,error:String(e instanceof Error?e.message:e),loading:!1})))},t);return be(()=>a(),[a]),{...n,reload:a}}function rt(e,t=250){let[n,r]=V(e);return be(()=>{let n=setTimeout(()=>r(e),t);return()=>clearTimeout(n)},[e,t]),n}function it(e){return e==null?``:e>=60?`${Math.floor(e/60)}h${e%60?` ${e%60}m`:``}`:`${e} min`}function Z(e){return e.replace(/-/g,` `)}function Q(e){let t=`chip${e.tone?` ${e.tone}`:``}`;return e.onClick?Y(`button`,{type:`button`,class:t,onClick:e.onClick,title:e.title,children:e.children}):Y(`span`,{class:t,title:e.title,children:e.children})}function at({r:e}){let t=e.images[0];return Y(`a`,{class:`card`,href:`/recipe/${e.slug}`,children:[t?Y(`div`,{class:`img`,style:{backgroundImage:`url("${q(t,480)}")`}}):Y(`div`,{class:`img none`,"aria-hidden":`true`,children:e.source_kind===`synthesized`?`✦`:`🍽`}),Y(`div`,{class:`body`,children:[Y(`div`,{class:`title`,children:e.title}),e.subtitle&&Y(`div`,{class:`sub`,children:e.subtitle}),Y(`div`,{class:`meta`,children:[e.total_min!=null&&Y(Q,{children:[`⏱ `,it(e.total_min)]}),e.tags.cuisine?.slice(0,1).map(e=>Y(Q,{children:Z(e)},e)),e.tags.diet?.slice(0,2).map(e=>Y(Q,{tone:`ok`,children:Z(e)},e)),e.needs_review&&Y(Q,{tone:`warn`,children:`needs review`})]})]})]})}function $({children:e}){return Y(`div`,{class:`empty`,children:e})}var ot=[`pending`,`done`,`skipped`,`failed`];function st(){let[e,t]=V(`pending`),n=X(()=>J.inbox(e),[e]),[r,i]=V(!1),[a,o]=V(null),[s,c]=V({}),l=async e=>{let r=[...e].filter(e=>/\.(jpe?g|png|webp|heic|heif|tiff?)$/i.test(e.name));if(!r.length)return;let i=[];o({done:0,total:r.length,errors:i});for(let[e,t]of r.entries()){try{await J.upload(t)}catch(e){i.push(`${t.name}: ${String(e)}`)}o({done:e+1,total:r.length,errors:i})}t(`pending`),n.reload()},u=async(e,t)=>{await J.resolve([e.id],t,s[e.id]),n.reload()},[d,f]=V(null),[p,m]=V(null),h=async(e,t)=>{f(e.id),m(null);try{await J.rotate(e.id,t),n.reload()}catch(t){m(`#${e.id}: ${String(t instanceof Error?t.message:t)}`)}finally{f(null)}},g=n.data?.counts;return Y(`div`,{class:`stack`,children:[Y(`label`,{class:`drop${r?` over`:``}`,onDragOver:e=>{e.preventDefault(),i(!0)},onDragLeave:()=>i(!1),onDrop:e=>{e.preventDefault(),i(!1),e.dataTransfer?.files&&l(e.dataTransfer.files)},children:[Y(`input`,{type:`file`,multiple:!0,accept:`image/*,.heic,.heif`,style:{display:`none`},onChange:e=>{let t=e.target.files;t&&l(t)}}),Y(`div`,{style:{fontSize:`1.1rem`,fontWeight:600},children:`Drop recipe card photos here, or click to choose`}),Y(`div`,{class:`small`,children:`JPEG, PNG, WebP or iPhone HEIC — fronts and backs. Exact duplicates are ignored automatically.`}),a&&Y(`div`,{class:`small`,style:{marginTop:`0.5rem`},children:[`Uploaded `,a.done,`/`,a.total,a.errors.map(e=>Y(`div`,{class:`error`,children:e},e))]})]}),g&&g.pending>0&&Y(`div`,{class:`notice`,children:[g.pending,` photo`,g.pending===1?``:`s`,` waiting. In Claude Code, run`,` `,Y(`b`,{children:`/pantrybase:ingest`}),` and Claude will read each card, pair fronts with backs, and save the recipes.`]}),Y(`div`,{class:`tabs`,children:ot.map(n=>Y(`button`,{type:`button`,class:n===e?`on`:``,onClick:()=>t(n),children:[n,` `,g?Y(`span`,{class:`muted`,children:g[n]}):null]},n))}),p&&Y(`p`,{class:`error small`,children:p}),n.data&&n.data.items.length===0?Y($,{children:[`Nothing `,e,`.`]}):Y(`div`,{class:`thumbgrid`,children:n.data?.items.map(e=>Y(`div`,{class:`thumb`,children:[Y(`a`,{href:q(e.path,1600,e.version),target:`_blank`,rel:`noreferrer`,children:Y(`img`,{src:q(e.path,360,e.version),alt:e.name,loading:`lazy`,style:d===e.id?{opacity:.4}:void 0})}),Y(`div`,{class:`tb`,children:[Y(`div`,{class:`row`,style:{justifyContent:`space-between`},children:[Y(`span`,{title:e.name,children:[`#`,e.id]}),Y(`span`,{class:`muted`,children:e.captured_at.slice(5,16).replace(`T`,` `)})]}),e.retake_of&&Y(Q,{tone:`warn`,title:`Looks like photo #${e.retake_of.id} (distance ${e.retake_of.distance})`,children:[`retake of #`,e.retake_of.id]}),e.rotation_hint&&Y(Q,{tone:`warn`,title:e.rotation_hint,children:`may be sideways`}),Y(`fieldset`,{class:`row bare`,"aria-label":`Rotate photo ${e.id}`,children:[[270,`↺`,`Rotate left`],[90,`↻`,`Rotate right`],[180,`180°`,`Turn upside down`]].map(([t,n,r])=>Y(`button`,{type:`button`,class:`small`,title:r,"aria-label":r,disabled:d!==null,onClick:()=>h(e,t),children:n},t))}),e.recipe_slug&&Y(`a`,{href:`/recipe/${e.recipe_slug}`,children:[`→ `,e.recipe_slug]}),e.reason&&Y(`span`,{class:`muted`,children:e.reason}),e.status!==`done`&&Y(x,{children:[e.status===`pending`&&Y(`input`,{type:`text`,placeholder:`reason (optional)`,value:s[e.id]??``,onInput:t=>c(n=>({...n,[e.id]:t.target.value}))}),Y(`div`,{class:`row`,children:e.status===`pending`?Y(x,{children:[Y(`button`,{type:`button`,class:`small`,onClick:()=>u(e,`skipped`),children:`Skip`}),Y(`button`,{type:`button`,class:`small`,onClick:()=>u(e,`failed`),children:`Unreadable`})]}):Y(`button`,{type:`button`,class:`small`,onClick:()=>u(e,`pending`),children:`Back to queue`})})]})]})]},e.id))})]})}function ct(){let e=X(()=>J.pantry(),[]),[t,n]=V(``),[r,i]=V(.5),[a,o]=V(null),s=rt(t,150),c=X(()=>s.trim().length>=2?J.ingredients(s.trim()):Promise.resolve([]),[s]),l=X(()=>J.match({min_coverage:r}),[r,e.data?.length]),u=async t=>{let r=t.split(`,`).map(e=>e.trim()).filter(Boolean);if(r.length){o(null);try{await J.pantryAdd(r),n(``),e.reload()}catch(e){o(String(e))}}},d=async t=>{await J.pantryRemove([t]),e.reload()};return Y(`div`,{class:`layout`,style:{gridTemplateColumns:`minmax(280px, 380px) 1fr`},children:[Y(`section`,{class:`panel stack`,children:[Y(`h2`,{children:`Your pantry`}),Y(`form`,{onSubmit:e=>{e.preventDefault(),u(t)},class:`row`,style:{flexWrap:`nowrap`},children:[Y(`input`,{type:`text`,list:`ingredient-suggestions`,placeholder:`Add items, e.g. eggs, baby marrow, rice`,value:t,onInput:e=>n(e.target.value)}),Y(`datalist`,{id:`ingredient-suggestions`,children:c.data?.map(e=>Y(`option`,{value:e.canonical},e.canonical))}),Y(`button`,{type:`submit`,class:`primary`,children:`Add`})]}),a&&Y(`p`,{class:`error small`,children:a}),e.data?.length?Y(`div`,{class:`row`,children:e.data.map(e=>Y(Q,{onClick:()=>d(e.canonical),title:`Remove`,children:[e.canonical,e.qty==null?``:` ×${e.qty}${e.unit?` ${e.unit}`:``}`,` `,Y(`span`,{class:`x`,children:`✕`})]},e.canonical))}):Y(`p`,{class:`muted small`,children:`Nothing yet. Salt, oil, water and other kitchen staples never count against a recipe, so skip those.`}),e.data&&e.data.length>0&&Y(`button`,{type:`button`,class:`ghost small`,onClick:async()=>{await J.pantryClear(),e.reload()},children:`Clear pantry`})]}),Y(`section`,{class:`panel`,children:[Y(`div`,{class:`row`,style:{justifyContent:`space-between`},children:[Y(`h2`,{children:`What can I cook?`}),Y(`label`,{class:`row small`,children:[`at least `,Math.round(r*100),`% covered`,Y(`input`,{type:`range`,min:0,max:1,step:.1,value:r,onInput:e=>i(Number(e.target.value))})]})]}),l.data&&l.data.results.length===0?Y($,{children:`No recipes reach that coverage yet — add more to your pantry or lower the bar.`}):l.data?.results.map(e=>Y(`div`,{class:`list-row`,children:[Y(`div`,{children:[Y(`a`,{href:`/recipe/${e.slug}`,children:Y(`b`,{children:e.title})}),e.near_miss&&Y(Q,{tone:`warn`,title:`1–2 items short`,children:`near miss`}),Y(`div`,{class:`row small`,style:{marginTop:`0.25rem`},children:e.missing.length?Y(x,{children:[Y(`span`,{class:`muted`,children:`missing`}),e.missing.map(e=>Y(Q,{onClick:()=>u(e.canonical),title:`Add to pantry`,children:[`+ `,Z(e.canonical)]},e.canonical))]}):Y(`span`,{class:`muted`,children:`you have everything`})})]}),Y(`div`,{children:[Y(`div`,{class:`bar`,children:Y(`span`,{style:{width:`${Math.round(e.coverage*100)}%`}})}),Y(`div`,{class:`muted small`,style:{textAlign:`right`},children:[e.have,`/`,e.need,` · `,Math.round(e.coverage*100),`%`]})]})]},e.slug))]})]})}var lt=[[`energy_kj`,`Energy`,`kJ`],[`energy_kcal`,`Energy`,`kcal`],[`protein_g`,`Protein`,`g`],[`carbs_g`,`Carbs`,`g`],[`sugars_g`,`of which sugars`,`g`],[`fibre_g`,`Fibre`,`g`],[`fat_g`,`Fat`,`g`],[`saturated_g`,`of which saturated`,`g`],[`sodium_mg`,`Sodium`,`mg`]];function ut(e){return[e.qty,e.unit].filter(e=>e!=null&&e!==``).join(` `)}function dt(){let{params:e}=We(),t=e.slug??``,n=X(()=>J.recipe(t),[t]),[r,i]=V(0),[a,o]=V(!1);if(n.error)return Y($,{children:n.error});let s=n.data;if(!s)return Y($,{children:`Loading…`});let c=s.recipe,l=new Set(s.pantry_have),u=c.ingredients.filter(e=>e.source!==`kitchen`),d=c.ingredients.filter(e=>e.source===`kitchen`),f=u.filter(e=>!e.staple&&!e.optional&&!l.has(e.canonical)),p=async e=>{o(!0);try{await J.pantryAdd(e),n.reload()}finally{o(!1)}},m=e=>{let t=l.has(e.canonical);return Y(`li`,{children:[Y(`span`,{class:`tick`,title:t?`in your pantry`:e.staple?`staple`:`not in pantry`,children:t?`✓`:e.staple?`·`:`○`}),Y(`span`,{children:[Y(`span`,{title:ut(e)?`${ut(e)} ${e.canonical}`:e.canonical,children:e.raw}),e.prep&&Y(`span`,{class:`prep`,children:[` — `,e.prep]}),e.optional&&!/optional/i.test(e.raw)&&Y(`span`,{class:`muted small`,children:` (optional)`}),e.alternatives?.length?Y(`span`,{class:`muted small`,children:[` · or `,e.alternatives.join(`, `)]}):null,e.components?.length?Y(`div`,{class:`muted small`,children:e.components.map(e=>e.raw).join(` + `)}):null]})]},`${e.raw}-${e.canonical}`)},h=c.nutrition,g=c.provider?.wine;return Y(`article`,{children:[Y(`div`,{class:`hero`,children:[Y(`div`,{class:`gallery`,children:c.images.length?Y(x,{children:[Y(`img`,{class:`main`,src:q(c.images[r]??c.images[0]??``,1400),alt:c.title}),c.images.length>1&&Y(`div`,{class:`thumbs`,children:c.images.map((e,t)=>Y(`button`,{type:`button`,class:`ghost`,style:{padding:0},onClick:()=>i(t),"aria-label":`Photo ${t+1}`,children:Y(`img`,{src:q(e,200),class:t===r?`sel`:``,alt:``})},e))})]}):Y(`div`,{class:`panel empty`,children:`No photo`})}),Y(`div`,{class:`stack`,children:[Y(`div`,{children:[Y(`h1`,{children:c.title}),c.subtitle&&Y(`div`,{class:`muted`,children:c.subtitle})]}),Y(`div`,{class:`row`,children:[`cuisine`,`dish`,`diet`,`protein`,`flavor`,`texture`,`season`].flatMap(e=>(c.tags[e]??[]).map(t=>Y(`a`,{href:`/?tags=${encodeURIComponent(JSON.stringify({[e]:[t]}))}`,children:Y(Q,{tone:e===`diet`?`ok`:void 0,children:Z(t)})},`${e}${t}`)))}),c.extraction?.needs_review&&Y(`div`,{class:`notice`,children:[`Flagged for review`,c.extraction.uncertain?.length?`: couldn't read ${c.extraction.uncertain.join(`, `)}`:``,`.`,c.extraction.notes?` ${c.extraction.notes}`:``]}),c.blurb&&Y(`p`,{children:c.blurb}),Y(`div`,{class:`facts`,children:[c.time.active!=null&&Y(`div`,{class:`fact`,children:[Y(`b`,{children:`Hands-on`}),it(c.time.active)]}),c.time.total!=null&&Y(`div`,{class:`fact`,children:[Y(`b`,{children:`Total`}),it(c.time.total)]}),c.servings!=null&&Y(`div`,{class:`fact`,children:[Y(`b`,{children:`Serves`}),c.servings]}),c.provider?.chef&&Y(`div`,{class:`fact`,children:[Y(`b`,{children:`Chef`}),c.provider.chef]}),c.provider&&Y(`div`,{class:`fact`,children:[Y(`b`,{children:`From`}),c.provider.name??c.provider.id,c.provider.plan?` · ${c.provider.plan}`:``]}),c.provider?.eat_within_days!=null&&Y(`div`,{class:`fact`,children:[Y(`b`,{children:`Eat within`}),c.provider.eat_within_days,` days`]}),g&&(g.name||g.producer)&&Y(`div`,{class:`fact`,children:[Y(`b`,{children:`Wine`}),[g.producer,g.name,g.vintage].filter(Boolean).join(` · `)]}),c.source.kind===`synthesized`&&Y(`div`,{class:`fact`,children:[Y(`b`,{children:`Source`}),`Synthesized`]})]}),c.description&&Y(`p`,{class:`muted small`,children:c.description})]})]}),Y(`div`,{class:`cols`,children:[Y(`section`,{class:`panel`,children:[Y(`div`,{class:`row`,style:{justifyContent:`space-between`},children:[Y(`h2`,{children:`Ingredients`}),f.length>0&&Y(`button`,{type:`button`,class:`small`,disabled:a,onClick:()=>p(f.map(e=>e.canonical)),children:`I have these`})]}),Y(`ul`,{class:`ings`,children:u.map(m)}),d.length>0&&Y(x,{children:[Y(`h3`,{style:{marginTop:`1rem`},children:`From your kitchen`}),Y(`ul`,{class:`ings`,children:d.map(m)})]}),Y(`p`,{class:`muted small`,children:`✓ in your pantry · ○ missing · · staple`})]}),Y(`section`,{class:`panel`,children:[Y(`h2`,{children:`Method`}),Y(`ol`,{class:`steps`,children:c.steps.map((e,t)=>Y(`li`,{children:[e.title&&Y(`div`,{class:`stitle`,children:e.title}),Y(`div`,{children:e.text}),Y(`div`,{class:`row`,style:{marginTop:`0.3rem`},children:[e.techniques?.map(e=>Y(Q,{children:Z(e)},e)),e.temp_c!=null&&Y(Q,{children:[`🌡 `,e.temp_c,`°C`]}),e.minutes?.length?Y(Q,{children:[`⏱ `,e.minutes.join(`–`),` min`]}):null]})]},t))}),c.tips.length>0&&Y(x,{children:[Y(`h3`,{children:`Tips`}),c.tips.map(e=>Y(`p`,{class:`small`,children:e},e))]}),c.notes&&Y(x,{children:[Y(`h3`,{children:`Notes`}),Y(`p`,{class:`small`,children:c.notes})]})]}),Y(`aside`,{class:`stack`,children:[h&&Y(`section`,{class:`panel`,children:[Y(`h2`,{children:`Nutrition`}),Y(`div`,{class:`muted small`,children:h.basis===`per_100g`?`per 100 g`:`per serving`}),Y(`table`,{class:`nut`,children:Y(`tbody`,{children:lt.filter(([e])=>h[e]!=null).map(([e,t,n])=>Y(`tr`,{children:[Y(`td`,{children:t}),Y(`td`,{children:[h[e],` `,n]})]},e))})})]}),(c.allergens.length>0||c.equipment.length>0)&&Y(`section`,{class:`panel stack`,children:[c.allergens.length>0&&Y(`div`,{children:[Y(`h3`,{children:`Allergens`}),Y(`div`,{class:`row`,children:c.allergens.map(e=>Y(Q,{tone:`bad`,children:Z(e)},e))})]}),c.equipment.length>0&&Y(`div`,{children:[Y(`h3`,{children:`Equipment`}),Y(`div`,{class:`row`,children:c.equipment.map(e=>Y(Q,{children:Z(e)},e))})]})]}),s.sources.length>0&&Y(`section`,{class:`panel`,children:[Y(`h3`,{children:`Inspired by`}),s.sources.map(e=>Y(`div`,{children:Y(`a`,{href:`/recipe/${e.slug}`,children:e.title})},e.slug))]}),s.derived.length>0&&Y(`section`,{class:`panel`,children:[Y(`h3`,{children:`Recipes built on this`}),s.derived.map(e=>Y(`div`,{children:Y(`a`,{href:`/recipe/${e.slug}`,children:e.title})},e.slug))]})]})]}),s.similar.length>0&&Y(`section`,{style:{marginTop:`2rem`},children:[Y(`h2`,{children:`Similar recipes`}),Y(`div`,{class:`grid`,children:s.similar.map(e=>Y(at,{r:e},e.slug))})]})]})}var ft=[`diet`,`cuisine`,`dish`,`protein`,`starch`,`flavor`,`texture`,`method`,`meal`,`time`,`season`,`technique`,`equipment`,`chef`,`plan`,`provider`],pt=[0,20,30,45,60];function mt(e){let t={};try{t=e.tags?JSON.parse(e.tags):{}}catch{t={}}return{q:e.q??``,tags:t,exclude:e.exclude?e.exclude.split(`,`):[],max:e.max?Number(e.max):0,review:e.review===`1`}}function ht(){let e=Ue(),t=mt(e.query),[n,r]=V(t.q),[i,a]=V(t.tags),[o,s]=V(t.exclude),[c,l]=V(t.max),[u,d]=V(t.review),[f,p]=V({}),m=rt(n);be(()=>{let t=new URLSearchParams;m&&t.set(`q`,m),Object.keys(i).length&&t.set(`tags`,JSON.stringify(i)),o.length&&t.set(`exclude`,o.join(`,`)),c&&t.set(`max`,String(c)),u&&t.set(`review`,`1`);let n=`/${t.toString()?`?${t}`:``}`;n!==`${e.path}${e.url.includes(`?`)?e.url.slice(e.url.indexOf(`?`)):``}`&&e.route(n,!0)},[m,i,o,c,u]);let h={tags:i,exclude_allergens:o,max_total_min:c||void 0,needs_review:u||void 0},g=X(()=>J.search(m,h),[m,JSON.stringify(h)]),_=X(()=>J.facets(),[]),v=(e,t)=>a(n=>{let r=new Set(n[e]??[]);r.has(t)?r.delete(t):r.add(t);let i={...n,[e]:[...r]};return i[e]?.length||delete i[e],i}),y=e=>s(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e]),b=Object.entries(i).flatMap(([e,t])=>t.map(t=>[e,t])),x=_.data?.used??{};return Y(`div`,{class:`layout`,children:[Y(`aside`,{class:`panel`,children:[Y(`div`,{class:`facet`,children:[Y(`h3`,{children:`Total time`}),Y(`select`,{value:c,onChange:e=>l(Number(e.target.value)),children:pt.map(e=>Y(`option`,{value:e,children:e?`≤ ${e} min`:`Any`},e))})]}),x.allergen?.length?Y(`div`,{class:`facet`,children:[Y(`h3`,{children:`Leave out allergens`}),Y(`div`,{class:`chips`,children:x.allergen.map(e=>Y(Q,{tone:o.includes(e.name)?`bad`:void 0,onClick:()=>y(e.name),children:[o.includes(e.name)?`✕ `:``,Z(e.name)]},e.name))})]}):null,ft.filter(e=>x[e]?.length).map(e=>{let t=x[e]??[],n=f[e]?t:t.slice(0,8);return Y(`div`,{class:`facet`,children:[Y(`h3`,{children:e}),Y(`div`,{class:`chips`,children:n.map(t=>Y(Q,{tone:i[e]?.includes(t.name)?`on`:void 0,onClick:()=>v(e,t.name),children:[Z(t.name),` `,Y(`span`,{class:`muted`,children:t.count})]},t.name))}),t.length>8&&Y(`button`,{type:`button`,class:`ghost more`,onClick:()=>p(t=>({...t,[e]:!t[e]})),children:f[e]?`fewer`:`+${t.length-8} more`})]},e)}),Y(`label`,{class:`row small`,children:[Y(`input`,{type:`checkbox`,checked:u,onChange:e=>d(e.target.checked)}),`Only recipes needing review`]})]}),Y(`section`,{class:`stack`,children:[Y(`input`,{type:`search`,placeholder:`Search recipes — a dish, an ingredient, a mood (“cosy weeknight curry”)…`,value:n,onInput:e=>r(e.target.value)}),(b.length>0||o.length>0||c>0)&&Y(`div`,{class:`row`,children:[b.map(([e,t])=>Y(Q,{tone:`on`,onClick:()=>v(e,t),children:[e,`: `,Z(t),` `,Y(`span`,{class:`x`,children:`✕`})]},`${e}:${t}`)),o.map(e=>Y(Q,{tone:`bad`,onClick:()=>y(e),children:[`no `,Z(e),` `,Y(`span`,{class:`x`,children:`✕`})]},e)),Y(`button`,{type:`button`,class:`ghost small`,onClick:()=>{a({}),s([]),l(0),d(!1)},children:`Clear all`})]}),g.error&&Y(`p`,{class:`error`,children:g.error}),Y(`div`,{class:`muted small`,children:[g.data?`${g.data.results.length} recipes`:`Loading…`,g.data&&m?g.data.vector_used?` · semantic + keyword`:` · keyword (semantic index warming up)`:``]}),g.data&&g.data.results.length===0?Y($,{children:[`No recipes match.`,` `,m||b.length?`Try fewer filters.`:`Add some — drop card photos on the Inbox page, then ask Claude to /pantrybase:ingest them.`]}):Y(`div`,{class:`grid`,children:g.data?.results.map(e=>Y(at,{r:e},e.slug))})]})]})}var gt=[`cuisine`,`dish`,`diet`,`protein`,`starch`,`flavor`,`texture`,`method`,`meal`,`time`,`season`,`allergen`,`technique`,`equipment`,`chef`,`plan`,`provider`,`other`];function _t(){let e=X(()=>J.facets(),[]),t=e.data?.used??{},n=gt.filter(e=>t[e]?.length);return e.data&&!n.length?Y($,{children:`No tags yet — they appear as recipes are saved.`}):Y(`div`,{class:`grid`,style:{gridTemplateColumns:`repeat(auto-fill, minmax(300px, 1fr))`},children:n.map(n=>Y(`section`,{class:`panel`,children:[Y(`h3`,{children:[n,` `,Y(`span`,{class:`muted`,children:[`· `,t[n]?.length]})]}),Y(`div`,{class:`row`,children:t[n]?.map(e=>Y(`a`,{href:n===`allergen`?`/?exclude=${encodeURIComponent(e.name)}`:`/?tags=${encodeURIComponent(JSON.stringify({[n]:[e.name]}))}`,children:Y(Q,{children:[Z(e.name),` `,Y(`span`,{class:`muted`,children:e.count})]})},e.name))}),e.data?.vocabulary[n]?.mode===`closed`&&Y(`div`,{class:`muted small`,style:{marginTop:`0.5rem`},children:`Controlled vocabulary`})]},n))})}var vt=Ke(()=>$e(()=>import(`./graph-qSs25SSD.js`).then(e=>e.GraphPage),[]));function yt(){let{path:e}=Ue(),t=X(()=>J.stats(),[e]),n=(t,n,r)=>Y(`a`,{class:`link${(t===`/`?e===`/`:e.startsWith(t))?` active`:``}`,href:t,children:[n,r?Y(`span`,{class:`badge`,children:r}):null]}),r=t.data;return Y(`nav`,{class:`nav`,children:[Y(`a`,{class:`brand`,href:`/`,children:[Y(`i`,{}),` pantrybase`]}),n(`/`,`Recipes`),n(`/pantry`,`Pantry`),n(`/graph`,`Graph`),n(`/inbox`,`Inbox`,r?.inbox.pending),n(`/tags`,`Tags`),Y(`span`,{class:`spacer`}),r&&Y(`span`,{class:`stat`,children:[r.recipes,` recipes · `,r.ingredients,` ingredients`,r.needs_review?` · ${r.needs_review} to review`:``]})]})}function bt(){return Y(Ie,{children:[Y(yt,{}),Y(`main`,{children:Y(Re,{children:[Y(G,{path:`/`,component:ht}),Y(G,{path:`/recipe/:slug`,component:dt}),Y(G,{path:`/pantry`,component:ct}),Y(G,{path:`/graph`,component:vt}),Y(G,{path:`/inbox`,component:st}),Y(G,{path:`/tags`,component:_t}),Y(G,{default:!0,component:ht})]})})]})}var xt=document.getElementById(`app`);xt&&I(Y(bt,{}),xt);export{J as a,be as c,V as d,Y as i,H as l,Z as n,q as o,X as r,Ue as s,Q as t,Se as u};
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Pantrybase</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%23c2410c'/><path d='M9 22V10h7a4 4 0 0 1 0 8h-7' stroke='white' stroke-width='3' fill='none' stroke-linecap='round'/></svg>" />
8
+ <script type="module" crossorigin src="/assets/index-lHc1E7AV.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Dsc3USAJ.css">
10
+ </head>
11
+ <body>
12
+ <div id="app"></div>
13
+ </body>
14
+ </html>
package/package.json CHANGED
@@ -1,6 +1,87 @@
1
1
  {
2
2
  "name": "pantrybase",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Local-first recipe knowledge base for Claude Code: capture meal-box recipe cards from photos, search by ingredient, tag, allergen or mood, match recipes to your pantry, explore them as a graph, and synthesize new ones.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22.12"
9
+ },
10
+ "bin": {
11
+ "pantrybase": "bin/pantrybase"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "!dist/**/*.map",
16
+ "bin",
17
+ "skills",
18
+ "seed/ingredients.yaml",
19
+ "seed/vocabulary.yaml",
20
+ "providers",
21
+ ".claude-plugin",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsup && vite build",
26
+ "build:server": "tsup",
27
+ "build:web": "vite build",
28
+ "dev": "concurrently -n server,web \"tsup --watch\" \"vite\"",
29
+ "dev:server": "tsx watch src/server.ts",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "lint": "biome check .",
33
+ "format": "biome format --write .",
34
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.web.json",
35
+ "check": "npm run lint && npm run typecheck && npm test",
36
+ "validate": "claude plugin validate .",
37
+ "prepack": "npm run build",
38
+ "version": "node scripts/sync-version.mjs && git add .claude-plugin/plugin.json"
39
+ },
40
+ "dependencies": {
41
+ "@huggingface/transformers": "^4.3.1",
42
+ "@modelcontextprotocol/sdk": "^1.32.1",
43
+ "better-sqlite3": "^13.0.3",
44
+ "fast-glob": "^3.3.3",
45
+ "sharp": "^0.35.5",
46
+ "sqlite-vec": "^0.1.9",
47
+ "yaml": "^2.9.1",
48
+ "zod": "^4.6.7"
49
+ },
50
+ "devDependencies": {
51
+ "@biomejs/biome": "^2.5.15",
52
+ "@preact/preset-vite": "^2.10.6",
53
+ "@types/better-sqlite3": "^7.6.13",
54
+ "@types/node": "^22.19.0",
55
+ "concurrently": "^9.2.0",
56
+ "graphology": "^0.26.0",
57
+ "graphology-layout-forceatlas2": "^0.10.1",
58
+ "graphology-types": "^0.24.8",
59
+ "preact": "^11.0.1",
60
+ "preact-iso": "^2.9.0",
61
+ "sigma": "^4.0.3",
62
+ "tsup": "^9.0.0",
63
+ "tsx": "^4.23.15",
64
+ "typescript": "^5.9.3",
65
+ "vite": "^8.3.4",
66
+ "vitest": "^5.0.5"
67
+ },
68
+ "optionalDependencies": {
69
+ "heic-convert": "^2.1.0"
70
+ },
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "git+https://github.com/rydn/pantrybase.git"
74
+ },
75
+ "homepage": "https://github.com/rydn/pantrybase#readme",
76
+ "keywords": [
77
+ "claude",
78
+ "claude-code",
79
+ "mcp",
80
+ "recipes",
81
+ "pantry",
82
+ "meal-kit",
83
+ "sqlite",
84
+ "vector-search"
85
+ ],
86
+ "author": "Ryan Dick"
87
+ }
@@ -0,0 +1,13 @@
1
+ # Generic recipe extraction guide
2
+
3
+ Use this for cards, printouts, cookbook pages or screenshots from a provider without a profile. Save with `provider: {id: "generic", name: "<provider name if printed>"}`, or leave `provider` out for personal recipes.
4
+
5
+ - `title` and `subtitle` as printed; `description` is any intro paragraph, transcribed.
6
+ - `servings`; `time.active` (hands-on), `time.total`, or `time.prep`/`time.cook` as printed, in minutes.
7
+ - `ingredients[]`: `raw` as printed, `canonical` as a generic singular name without brands (put the brand in `brand`), `qty`/`unit`/`prep`. Mark things you're told to have at home as `source: "kitchen"` and the rest `source: "box"` (or leave it out). Note optional items and alternatives.
8
+ - `steps[]`: `text` per step, plus a `title` if the step has a heading; infer `techniques`, `uses` (canonical ingredient names), `equipment`, `temp_c` and `minutes` ([min, max]).
9
+ - `nutrition` only if printed; say whether it is `per_serving` or `per_100g`.
10
+ - `allergens` as printed. Tips or variations go in `tips[]`.
11
+ - Tags: protein, diet, cuisine (most specific), dish, starch, flavor, texture, method, meal, season.
12
+ - `blurb`: 1–2 sentences in your own words about what the dish is, how it tastes and when you'd want it.
13
+ - Put anything unreadable in `extraction.uncertain`, set `extraction.needs_review` when a key part is missing, and never guess numbers.
@@ -0,0 +1,17 @@
1
+ id: generic
2
+ name: Generic recipe
3
+ country: null
4
+ recognise: []
5
+ sides:
6
+ front: [title, photo]
7
+ back: [ingredients, method]
8
+ pairing: content
9
+ orientation: null
10
+ nutrition_basis: per_serving
11
+ default_servings: null
12
+ kitchen_section: null
13
+ plan_labels: {}
14
+ allergen_synonyms: {}
15
+ brands: []
16
+ equipment_terms: {}
17
+ ingredient_aliases: {}
@@ -0,0 +1,62 @@
1
+ # UCOOK card extraction guide
2
+
3
+ UCOOK (South Africa) cards are landscape, two-sided. Read every photo of a card before saving. A card photo that is taller than it is wide, or whose text runs sideways or upside down, needs `pb_inbox_rotate` (clockwise degrees) before you read it. Save with `provider: {id: "ucook", …}` and `source: {kind: "card"}`.
4
+
5
+ ## Front (photo left, text panel right)
6
+
7
+ | Card text | Field |
8
+ |---|---|
9
+ | Large title | `title` |
10
+ | "with …" line under the title | `subtitle` (keep the "with") |
11
+ | Paragraph | `description` — transcribe as printed |
12
+ | Hands-on Time | `time.active` (minutes) |
13
+ | Overall Time | `time.total` (minutes) |
14
+ | Serves: 2 People | `servings` |
15
+ | Chef: … | `provider.chef` (may be a person or a winery) |
16
+ | Plan line, e.g. "Veggie", "Quick & Easy" | `provider.plan` exactly as printed |
17
+ | "Producer \| Wine Name Vintage" | `provider.wine: {producer, name, vintage}` — `name` without the producer prefix where it repeats |
18
+
19
+ ## Back (no title — match it to its front by order and ingredients)
20
+
21
+ **Ingredients & Prep** → one `ingredients[]` entry per line, `source: "box"`:
22
+ - `raw`: quantity + name as printed ("200g Large Pasta Shells").
23
+ - `qty`/`unit`: "200g" → 200/g, "20ml" → 20/ml, "1" → 1 with no unit, "½" → 0.5.
24
+ - `prep`: the orange italic line ("rinse & roughly shred").
25
+ - `canonical`: generic singular name without the brand ("NOMU Cajun Rub" → `cajun rub`, `brand: "NOMU"`).
26
+ - Composite lines like "Creamy Pesto (60ml Pesto Princess Basil Pesto & 40ml Sour Cream)": canonical is the composite (`creamy pesto`), with `components[]` for each part.
27
+
28
+ **From Your Kitchen** → `ingredients[]` with `source: "kitchen"`:
29
+ - "Oil (cooking, olive or coconut)" → canonical `vegetable oil`, `alternatives: [olive oil, coconut oil]`.
30
+ - "Salt & Pepper" → two entries, `salt` and `black pepper`.
31
+ - "Sugar/Sweetener/Honey" → `sugar`, `alternatives: [sweetener, honey]`.
32
+ - "(optional)" → `optional: true`. "Milk or Milk Alternative" → `milk`, `alternatives: [plant milk]`.
33
+ - Equipment here (Blender, Paper Towel) goes in `equipment`, not ingredients.
34
+
35
+ **Numbered steps** → `steps[]`, in order:
36
+ - `title`: the orange heading as printed, in sentence case ("Wal street"). Headings are often puns, so never treat them as techniques.
37
+ - `text`: the full step text.
38
+ - `techniques`: infer from the text using the method vocabulary (roast, pan-fry, saute, boil, simmer, toast, blend, pickle, bake, grill…).
39
+ - `uses`: canonical names of the ingredients this step uses.
40
+ - `equipment`: what the step uses (oven, pan, pot, roasting-tray, blender, ovenproof-dish, kettle, microwave).
41
+ - `temp_c`: oven temperature if given. "grill setting or the highest temperature" → equipment `grill`, no temp.
42
+ - `minutes`: the cooking time, `[min, max]` for a range ("25-30 minutes" → [25, 30]).
43
+
44
+ **Chef's Tip** box → `tips[]` (it often gives an air-fryer method; add `air-fryer` to `equipment` only if the tip is the main method).
45
+
46
+ **Nutritional Information — Per 100g** → `nutrition` with `basis: "per_100g"`: energy_kj, energy_kcal, protein_g, carbs_g, sugars_g ("of which sugars"), fibre_g, fat_g, saturated_g ("of which saturated"), sodium_mg.
47
+
48
+ **Allergens** → `allergens[]` as printed ("Cow's Milk" and "Dairy" both become `dairy`; the server maps them).
49
+
50
+ **Eat Within N Days / Cook within N Days** → `provider.eat_within_days`.
51
+
52
+ ## Tags and blurb
53
+ - Plan labels map automatically ("Veggie" → diet vegetarian), so add only what you can infer beyond them.
54
+ - Infer `tags`: protein (main protein, e.g. legumes, tofu, cheese), cuisine (most specific: "thai" not "asian"), dish, starch, flavor, texture, method, meal, season.
55
+ - `diet`: add `vegan` only if no animal products; `vegan-adaptable` if only optional items are animal products.
56
+ - `blurb`: 1–2 sentences in your own words describing what the dish is, how it tastes and when you'd want it. It is used for semantic search, so include mood and occasion ("warming", "weeknight").
57
+
58
+ ## Quality
59
+ - Glare, smudges, cropping: set unreadable fields to null and list them in `extraction.uncertain` ("nutrition.protein_g", "allergens"). Never guess numbers.
60
+ - A front with no back photo: save what the front gives, mark `extraction.needs_review: true` and `uncertain: [ingredients, steps, nutrition, allergens]`.
61
+ - Two photos of the same side (retakes): use the clearer one and list both in `extraction.images`.
62
+ - Set `extraction.confidence` (0–1) honestly.
@@ -0,0 +1,62 @@
1
+ id: ucook
2
+ name: UCOOK
3
+ country: ZA
4
+ website: https://ucook.co.za
5
+ # Text that identifies a UCOOK card in a photo.
6
+ recognise: [UCOOK, "#lovingucook", "@ucooksa", "Join the UCOOK community"]
7
+ sides:
8
+ front: [title, "subtitle starting with 'with'", Hands-on Time, Overall Time, Serves, Chef, wine pairing]
9
+ back: [Ingredients & Prep, From Your Kitchen, Nutritional Information, Allergens, Eat Within]
10
+ # Photos are usually shot front then back; the back carries no title, so pair by order and content.
11
+ pairing: front-then-back
12
+ # Upright cards are wider than tall; a portrait photo of one is almost certainly sideways.
13
+ orientation: landscape
14
+ nutrition_basis: per_100g
15
+ default_servings: 2
16
+ # Heading of the card section listing what you supply yourself (ingredients get source: kitchen).
17
+ kitchen_section: From Your Kitchen
18
+
19
+ # Plan labels printed on the front → tags.
20
+ plan_labels:
21
+ Veggie: {diet: [vegetarian]}
22
+ Vegetarian: {diet: [vegetarian]}
23
+ Vegan: {diet: [vegan]}
24
+ Quick & Easy: {time: [quick]}
25
+ Carb Conscious: {diet: [low-carb]}
26
+ Calorie Conscious: {diet: [low-calorie]}
27
+ Family: {diet: [family-friendly]}
28
+ Plant-based: {diet: [vegan]}
29
+
30
+ # Card allergen wording → vocabulary values (both card generations).
31
+ allergen_synonyms:
32
+ Cow's Milk: dairy
33
+ Dairy: dairy
34
+ Tree Nuts: tree-nuts
35
+ Sulphites: sulphites
36
+ Allium: allium
37
+ Soy: soy
38
+
39
+ # Brand names stripped from canonical ingredient names (kept in `brand`).
40
+ brands: [NOMU, Pesto Princess, Outcast, Old Stone Mill, Chevin, Fry's, Lancewood, Clover, Woolworths]
41
+
42
+ # "From Your Kitchen" items that are equipment, not ingredients.
43
+ equipment_terms:
44
+ Paper Towel: paper-towel
45
+ Blender: blender
46
+ Food Processor: blender
47
+ Microwave: microwave
48
+ Air Fryer: air-fryer
49
+ Kettle: kettle
50
+ Ovenproof Dish: ovenproof-dish
51
+
52
+ # Regional ingredient names → canonical.
53
+ ingredient_aliases:
54
+ courgette: [baby marrow, baby marrows]
55
+ aubergine: [brinjal, brinjals]
56
+ bell pepper: [piquanté pepper, piquante peppers]
57
+ flour: [cake flour]
58
+ cherry tomatoes: [baby tomatoes]
59
+ goat's cheese: [chevin, chevin goat's cheese]
60
+ milk: [low fat uht milk, uht milk, fresh milk]
61
+ chopped tomatoes: [cooked chopped tomato, cooked chopped tomatoes]
62
+ carrot: [julienne carrots, carrot wedges]
@@ -0,0 +1,194 @@
1
+ # Ingredient catalog seed. Copied to ./pantrybase/ingredients.yaml on first use; edit that copy.
2
+ # canonical name: {aliases: [...], staple: true|false, category: ...}
3
+ # Staples never count against pantry coverage.
4
+
5
+ # --- staples -------------------------------------------------------------
6
+ salt: {staple: true, category: seasoning, aliases: [sea salt, kosher salt, table salt, flaky salt, salt and pepper]}
7
+ black pepper: {staple: true, category: seasoning, aliases: [pepper, ground black pepper, cracked black pepper]}
8
+ water: {staple: true, category: other, aliases: [boiling water, cold water, warm water]}
9
+ olive oil: {staple: true, category: oil, aliases: [extra virgin olive oil, evoo]}
10
+ vegetable oil: {staple: true, category: oil, aliases: [oil, cooking oil, sunflower oil, rapeseed oil, canola oil, neutral oil]}
11
+ butter: {staple: true, category: dairy, aliases: [unsalted butter, salted butter]}
12
+ sugar: {staple: true, category: baking, aliases: [white sugar, granulated sugar, caster sugar]}
13
+ brown sugar: {staple: true, category: baking, aliases: [light brown sugar, dark brown sugar, demerara sugar]}
14
+ flour: {staple: true, category: baking, aliases: [plain flour, all-purpose flour, all purpose flour, wheat flour]}
15
+ cornflour: {staple: true, category: baking, aliases: [cornstarch, corn starch, corn flour]}
16
+ baking powder: {staple: true, category: baking}
17
+ white wine vinegar: {staple: true, category: condiment}
18
+ red wine vinegar: {staple: true, category: condiment}
19
+ rice vinegar: {staple: true, category: condiment, aliases: [rice wine vinegar]}
20
+ balsamic vinegar: {staple: true, category: condiment}
21
+ soy sauce: {staple: true, category: condiment, aliases: [light soy sauce, dark soy sauce, shoyu, tamari]}
22
+ honey: {staple: true, category: condiment}
23
+ dijon mustard: {staple: true, category: condiment, aliases: [mustard, wholegrain mustard]}
24
+ ketchup: {staple: true, category: condiment, aliases: [tomato ketchup]}
25
+ mayonnaise: {staple: true, category: condiment, aliases: [mayo]}
26
+ chicken stock: {staple: true, category: stock, aliases: [chicken broth, chicken stock cube, chicken stock pot, chicken bouillon]}
27
+ vegetable stock: {staple: true, category: stock, aliases: [vegetable broth, veg stock, vegetable stock cube, vegetable stock pot, vegetable bouillon]}
28
+ beef stock: {staple: true, category: stock, aliases: [beef broth, beef stock cube, beef stock pot]}
29
+ garlic: {staple: true, category: vegetable, aliases: [garlic clove, garlic cloves, clove of garlic, minced garlic]}
30
+ paprika: {staple: true, category: spice, aliases: [sweet paprika]}
31
+ smoked paprika: {staple: true, category: spice}
32
+ cumin: {staple: true, category: spice, aliases: [ground cumin, cumin seeds]}
33
+ coriander seed: {staple: true, category: spice, aliases: [ground coriander, coriander seeds]}
34
+ turmeric: {staple: true, category: spice, aliases: [ground turmeric]}
35
+ cinnamon: {staple: true, category: spice, aliases: [ground cinnamon, cinnamon stick]}
36
+ chilli flakes: {staple: true, category: spice, aliases: [chili flakes, red pepper flakes, crushed chillies, dried chilli flakes]}
37
+ chilli powder: {staple: true, category: spice, aliases: [chili powder, cayenne pepper, cayenne]}
38
+ curry powder: {staple: true, category: spice}
39
+ garam masala: {staple: true, category: spice}
40
+ dried oregano: {staple: true, category: herb, aliases: [oregano]}
41
+ dried thyme: {staple: true, category: herb}
42
+ mixed herbs: {staple: true, category: herb, aliases: [italian herbs, italian seasoning]}
43
+ bay leaf: {staple: true, category: herb, aliases: [bay leaves]}
44
+ sesame oil: {staple: true, category: oil, aliases: [toasted sesame oil]}
45
+
46
+ # --- proteins ------------------------------------------------------------
47
+ chicken breast: {category: protein, aliases: [chicken breasts, chicken breast fillet, diced chicken breast, chicken]}
48
+ chicken thigh: {category: protein, aliases: [chicken thighs, boneless chicken thigh, chicken thigh fillet]}
49
+ chicken drumstick: {category: protein, aliases: [chicken drumsticks]}
50
+ whole chicken: {category: protein}
51
+ beef mince: {category: protein, aliases: [ground beef, minced beef, beef mince 5%, lean beef mince]}
52
+ beef steak: {category: protein, aliases: [sirloin steak, rump steak, steak, flat iron steak, bavette]}
53
+ beef strips: {category: protein, aliases: [beef stir-fry strips, stir fry beef]}
54
+ pork mince: {category: protein, aliases: [ground pork, minced pork]}
55
+ pork loin: {category: protein, aliases: [pork loin steak, pork loin steaks, pork chop, pork chops]}
56
+ pork belly: {category: protein}
57
+ pork sausage: {category: protein, aliases: [sausages, pork sausages, cumberland sausage]}
58
+ chorizo: {category: protein, aliases: [cooking chorizo, chorizo sausage]}
59
+ bacon: {category: protein, aliases: [streaky bacon, bacon rashers, bacon lardons, smoked bacon, pancetta]}
60
+ lamb mince: {category: protein, aliases: [ground lamb, minced lamb]}
61
+ lamb leg steak: {category: protein, aliases: [lamb steak, lamb steaks]}
62
+ turkey mince: {category: protein, aliases: [ground turkey, minced turkey]}
63
+ salmon fillet: {category: seafood, aliases: [salmon, salmon fillets, skin-on salmon]}
64
+ cod fillet: {category: seafood, aliases: [cod, cod fillets, white fish, white fish fillet, haddock, basa fillet]}
65
+ sea bass fillet: {category: seafood, aliases: [sea bass]}
66
+ prawns: {category: seafood, aliases: [king prawns, shrimp, raw prawns, prawn]}
67
+ tuna: {category: seafood, aliases: [tinned tuna, canned tuna, tuna steak]}
68
+ smoked salmon: {category: seafood}
69
+ egg: {category: protein, aliases: [eggs, free-range egg, large egg, medium egg]}
70
+ tofu: {category: protein, aliases: [firm tofu, extra firm tofu, smoked tofu]}
71
+ halloumi: {category: dairy, aliases: [halloumi cheese]}
72
+ paneer: {category: dairy}
73
+ chickpeas: {category: legume, aliases: [chickpea, tinned chickpeas, canned chickpeas, garbanzo beans]}
74
+ black beans: {category: legume, aliases: [tinned black beans, canned black beans]}
75
+ kidney beans: {category: legume, aliases: [red kidney beans]}
76
+ cannellini beans: {category: legume, aliases: [white beans, butter beans, haricot beans]}
77
+ lentils: {category: legume, aliases: [red lentils, green lentils, puy lentils, dried lentils, tinned lentils]}
78
+
79
+ # --- starches ------------------------------------------------------------
80
+ rice: {category: grain, aliases: [basmati rice, long grain rice, white rice, jasmine rice, brown rice]}
81
+ risotto rice: {category: grain, aliases: [arborio rice, carnaroli rice]}
82
+ pasta: {category: grain, aliases: [penne, rigatoni, fusilli, conchiglie, dried pasta, pasta shapes]}
83
+ spaghetti: {category: grain, aliases: [linguine, tagliatelle, bucatini]}
84
+ orzo: {category: grain}
85
+ gnocchi: {category: grain}
86
+ egg noodles: {category: grain, aliases: [noodles, medium egg noodles, ramen noodles, udon noodles]}
87
+ rice noodles: {category: grain, aliases: [vermicelli rice noodles, flat rice noodles, pad thai noodles]}
88
+ potato: {category: vegetable, aliases: [potatoes, white potatoes, maris piper potatoes, baby potatoes, new potatoes]}
89
+ sweet potato: {category: vegetable, aliases: [sweet potatoes]}
90
+ couscous: {category: grain, aliases: [giant couscous, pearl couscous]}
91
+ bulgur wheat: {category: grain, aliases: [bulgur]}
92
+ quinoa: {category: grain}
93
+ tortilla: {category: grain, aliases: [tortillas, flour tortillas, corn tortillas, wraps, tortilla wraps]}
94
+ bread: {category: grain, aliases: [crusty bread, sourdough, baguette, ciabatta, bread roll, burger bun, brioche bun, pitta, pitta bread, naan, naan bread, flatbread]}
95
+ breadcrumbs: {category: grain, aliases: [panko breadcrumbs, panko]}
96
+ polenta: {category: grain}
97
+
98
+ # --- vegetables ----------------------------------------------------------
99
+ onion: {category: vegetable, aliases: [onions, brown onion, white onion, yellow onion]}
100
+ red onion: {category: vegetable, aliases: [red onions]}
101
+ shallot: {category: vegetable, aliases: [shallots, echalion shallot, banana shallot]}
102
+ spring onion: {category: vegetable, aliases: [spring onions, scallions, scallion, green onion, green onions]}
103
+ leek: {category: vegetable, aliases: [leeks]}
104
+ carrot: {category: vegetable, aliases: [carrots]}
105
+ celery: {category: vegetable, aliases: [celery stick, celery sticks]}
106
+ bell pepper: {category: vegetable, aliases: [pepper, peppers, red pepper, green pepper, yellow pepper, orange pepper, red bell pepper, capsicum, mixed peppers]}
107
+ courgette: {category: vegetable, aliases: [courgettes, zucchini]}
108
+ aubergine: {category: vegetable, aliases: [aubergines, eggplant]}
109
+ tomato: {category: vegetable, aliases: [tomatoes, vine tomatoes, plum tomatoes, salad tomatoes]}
110
+ cherry tomatoes: {category: vegetable, aliases: [cherry tomato, baby plum tomatoes, cherry vine tomatoes]}
111
+ chopped tomatoes: {category: vegetable, aliases: [tinned tomatoes, canned tomatoes, tin of tomatoes, finely chopped tomatoes, diced tomatoes, passata]}
112
+ tomato puree: {category: condiment, aliases: [tomato paste, tomato purée]}
113
+ broccoli: {category: vegetable, aliases: [tenderstem broccoli, broccolini, broccoli florets]}
114
+ cauliflower: {category: vegetable, aliases: [cauliflower florets]}
115
+ cabbage: {category: vegetable, aliases: [white cabbage, red cabbage, savoy cabbage, pointed cabbage, sweetheart cabbage]}
116
+ kale: {category: vegetable, aliases: [cavolo nero, curly kale]}
117
+ spinach: {category: vegetable, aliases: [baby spinach, spinach leaves]}
118
+ lettuce: {category: vegetable, aliases: [little gem lettuce, romaine lettuce, iceberg lettuce, mixed salad leaves, salad leaves, rocket, arugula, baby leaf salad]}
119
+ cucumber: {category: vegetable}
120
+ green beans: {category: vegetable, aliases: [fine beans, french beans]}
121
+ peas: {category: vegetable, aliases: [garden peas, frozen peas, petits pois]}
122
+ sugar snap peas: {category: vegetable, aliases: [mangetout, snow peas]}
123
+ sweetcorn: {category: vegetable, aliases: [corn, tinned sweetcorn, corn on the cob, corn cob]}
124
+ mushroom: {category: vegetable, aliases: [mushrooms, chestnut mushrooms, closed cup mushrooms, button mushrooms, portobello mushroom, shiitake mushrooms]}
125
+ butternut squash: {category: vegetable, aliases: [squash, pumpkin]}
126
+ beetroot: {category: vegetable, aliases: [beets, cooked beetroot]}
127
+ asparagus: {category: vegetable}
128
+ pak choi: {category: vegetable, aliases: [bok choy, pak choy]}
129
+ avocado: {category: fruit, aliases: [avocados]}
130
+ ginger: {category: vegetable, aliases: [fresh ginger, root ginger, ginger paste, grated ginger]}
131
+ red chilli: {category: vegetable, aliases: [chilli, chili, fresh chilli, red chili, green chilli, birds eye chilli, jalapeno, jalapeño]}
132
+ lemon: {category: fruit, aliases: [lemons, lemon juice, lemon zest]}
133
+ lime: {category: fruit, aliases: [limes, lime juice, lime zest]}
134
+ orange: {category: fruit, aliases: [oranges, orange juice, orange zest]}
135
+ apple: {category: fruit, aliases: [apples]}
136
+ mango: {category: fruit, aliases: [mango chutney]}
137
+ pomegranate: {category: fruit, aliases: [pomegranate seeds]}
138
+
139
+ # --- herbs ---------------------------------------------------------------
140
+ coriander: {category: herb, aliases: [fresh coriander, cilantro, coriander leaves]}
141
+ parsley: {category: herb, aliases: [flat leaf parsley, flat-leaf parsley, fresh parsley, curly parsley]}
142
+ basil: {category: herb, aliases: [fresh basil, basil leaves]}
143
+ mint: {category: herb, aliases: [fresh mint, mint leaves]}
144
+ dill: {category: herb, aliases: [fresh dill]}
145
+ chives: {category: herb}
146
+ thyme: {category: herb, aliases: [fresh thyme]}
147
+ rosemary: {category: herb, aliases: [fresh rosemary]}
148
+ sage: {category: herb}
149
+ lemongrass: {category: herb}
150
+
151
+ # --- dairy ---------------------------------------------------------------
152
+ milk: {category: dairy, aliases: [whole milk, semi-skimmed milk, skimmed milk]}
153
+ cream: {category: dairy, aliases: [double cream, single cream, heavy cream, whipping cream]}
154
+ creme fraiche: {category: dairy, aliases: [crème fraîche, half fat creme fraiche]}
155
+ sour cream: {category: dairy, aliases: [soured cream]}
156
+ yoghurt: {category: dairy, aliases: [yogurt, greek yoghurt, greek yogurt, natural yoghurt, natural yogurt]}
157
+ cheddar: {category: dairy, aliases: [cheddar cheese, mature cheddar, grated cheddar, grated cheese, cheese]}
158
+ parmesan: {category: dairy, aliases: [parmesan cheese, grated parmesan, italian hard cheese, grana padano, pecorino]}
159
+ mozzarella: {category: dairy, aliases: [mozzarella cheese, grated mozzarella]}
160
+ feta: {category: dairy, aliases: [feta cheese]}
161
+ goat's cheese: {category: dairy, aliases: [goats cheese, goat cheese, soft goat's cheese]}
162
+ cream cheese: {category: dairy, aliases: [soft cheese, philadelphia]}
163
+ mascarpone: {category: dairy}
164
+ ricotta: {category: dairy}
165
+ coconut milk: {category: other, aliases: [tinned coconut milk, canned coconut milk, coconut cream]}
166
+
167
+ # --- condiments & flavourings ------------------------------------------
168
+ harissa: {category: condiment, aliases: [harissa paste, rose harissa]}
169
+ curry paste: {category: condiment, aliases: [thai red curry paste, thai green curry paste, red curry paste, green curry paste, massaman paste]}
170
+ miso paste: {category: condiment, aliases: [miso, white miso, red miso]}
171
+ fish sauce: {category: condiment}
172
+ oyster sauce: {category: condiment}
173
+ hoisin sauce: {category: condiment}
174
+ sriracha: {category: condiment, aliases: [hot sauce, chilli sauce, sweet chilli sauce]}
175
+ gochujang: {category: condiment, aliases: [korean chilli paste]}
176
+ tahini: {category: condiment}
177
+ peanut butter: {category: condiment}
178
+ pesto: {category: condiment, aliases: [green pesto, basil pesto, red pesto]}
179
+ capers: {category: condiment}
180
+ olives: {category: condiment, aliases: [black olives, green olives, kalamata olives, pitted olives]}
181
+ sun-dried tomatoes: {category: condiment, aliases: [sundried tomatoes]}
182
+ worcestershire sauce: {category: condiment}
183
+ white wine: {category: other}
184
+ red wine: {category: other}
185
+ sesame seeds: {category: nut, aliases: [toasted sesame seeds]}
186
+ peanuts: {category: nut, aliases: [roasted peanuts, salted peanuts]}
187
+ cashews: {category: nut, aliases: [cashew nuts]}
188
+ almonds: {category: nut, aliases: [flaked almonds, toasted almonds, ground almonds]}
189
+ pine nuts: {category: nut}
190
+ walnuts: {category: nut}
191
+ coconut: {category: other, aliases: [desiccated coconut, coconut flakes]}
192
+ puff pastry: {category: baking, aliases: [ready rolled puff pastry]}
193
+ chocolate: {category: baking, aliases: [dark chocolate, milk chocolate, chocolate chips]}
194
+ vanilla extract: {category: baking, aliases: [vanilla]}