<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Creative Coffee Designer | 特调咖啡饮品设计器</title>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<style>
:root{
--bg:#f5f2ec;
--card:#ffffff;
--ink:#1f2937;
--muted:#6b7280;
--line:#e5e7eb;
--accent:#7a5c44;
--accent-2:#b08a6a;
--good:#0f766e;
--warn:#b45309;
--danger:#b91c1c;
--shadow: 0 10px 30px rgba(17,24,39,.10);
--radius: 18px;
--radius-2: 24px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft Yahei", "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 700px at 20% -10%, rgba(176,138,106,.25), transparent 55%),
radial-gradient(900px 600px at 90% 0%, rgba(122,92,68,.18), transparent 50%),
var(--bg);
color:var(--ink);
}
a{color:inherit}
.wrap{max-width:1100px; margin:0 auto; padding:18px 14px 40px;}
header{
display:flex; align-items:flex-start; justify-content:space-between;
gap:14px; padding:16px; border:1px solid var(--line);
background: rgba(255,255,255,.7); backdrop-filter: blur(10px);
border-radius: var(--radius-2); box-shadow: var(--shadow);
}
.brand{display:flex; flex-direction:column; gap:6px}
.brand h1{margin:0; font-size:18px; letter-spacing:.2px}
.brand p{margin:0; color:var(--muted); font-size:12.5px; line-height:1.35}
.topActions{display:flex; flex-wrap:wrap; gap:8px; align-items:center; justify-content:flex-end}
button, .btn{
appearance:none; border:1px solid var(--line); background:#fff; color:var(--ink);
padding:10px 12px; border-radius: 999px; cursor:pointer;
box-shadow: 0 6px 18px rgba(17,24,39,.06);
font-weight:650; font-size:13px;
transition: transform .05s ease, border-color .2s ease, box-shadow .2s ease;
user-select:none;
}
button:hover{border-color: rgba(122,92,68,.45)}
button:active{transform: translateY(1px)}
.btnPrimary{
background: linear-gradient(180deg, rgba(122,92,68,1), rgba(122,92,68,.92));
color:#fff; border-color: rgba(122,92,68,.2);
}
.btnGhost{background: rgba(255,255,255,.65)}
.btnDanger{border-color: rgba(185,28,28,.35); color: var(--danger)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:8px 10px; border-radius:999px;
background: rgba(255,255,255,.7);
border:1px solid var(--line); color: var(--muted); font-size:12px;
}
main{margin-top:14px; display:grid; grid-template-columns: 1.1fr .9fr; gap:14px}
@media (max-width: 980px){ main{grid-template-columns:1fr; } }
.card{
background: rgba(255,255,255,.75);
border:1px solid var(--line);
border-radius: var(--radius-2);
box-shadow: var(--shadow);
overflow:hidden;
}
.cardHead{
padding:14px 14px 10px;
display:flex; align-items:flex-start; justify-content:space-between; gap:10px;
border-bottom:1px solid rgba(229,231,235,.7);
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(255,255,255,.65));
}
.cardTitle{display:flex; flex-direction:column; gap:6px}
.cardTitle h2{margin:0; font-size:15px}
.cardTitle p{margin:0; color:var(--muted); font-size:12px; line-height:1.35}
.cardBody{padding:14px}
.tabs{display:flex; gap:8px; flex-wrap:wrap}
.tab{
padding:8px 10px; border-radius: 999px;
border:1px solid var(--line); background: rgba(255,255,255,.65);
cursor:pointer; font-weight:700; font-size:12.5px; color: var(--muted);
}
.tab[aria-selected="true"]{
color: var(--ink);
border-color: rgba(122,92,68,.45);
background: rgba(176,138,106,.16);
}
.grid2{display:grid; grid-template-columns: 1fr 1fr; gap:10px}
@media (max-width: 520px){ .grid2{grid-template-columns:1fr} }
label{display:block; font-size:12px; color: var(--muted); margin-bottom:6px}
input[type="text"], input[type="number"], textarea, select{
width:100%;
padding:10px 11px;
border-radius: 14px;
border:1px solid var(--line);
background: rgba(255,255,255,.9);
outline:none;
font-size:13.5px;
}
textarea{min-height:86px; resize: vertical; line-height:1.4}
.row{display:flex; gap:10px; align-items:center; flex-wrap:wrap}
.row > *{flex:1}
.hint{font-size:12px; color:var(--muted); line-height:1.35}
.sep{height:1px; background: rgba(229,231,235,.8); margin:12px 0}
.mini{
font-size:11.5px; color: var(--muted);
font-family: var(--mono);
background: rgba(255,255,255,.6);
border:1px dashed rgba(229,231,235,.9);
padding:10px; border-radius: 14px;
overflow:auto;
white-space: pre-wrap;
}
.chips{display:flex; flex-wrap:wrap; gap:8px}
.chip{
display:inline-flex; align-items:center; gap:8px;
border:1px solid rgba(229,231,235,.9);
padding:8px 10px; border-radius: 999px;
background: rgba(255,255,255,.75);
font-size:12.5px;
}
.chip small{color: var(--muted); font-weight:650}
.chip .x{border:none; background:transparent; box-shadow:none; padding:0; cursor:pointer; color: var(--muted)}
.badge{
display:inline-flex; align-items:center; gap:6px;
padding:6px 8px; border-radius: 999px;
font-size:12px; font-weight:800;
border:1px solid rgba(229,231,235,.9);
background: rgba(255,255,255,.7);
}
.badge.good{color: var(--good); border-color: rgba(15,118,110,.25); background: rgba(15,118,110,.08)}
.badge.warn{color: var(--warn); border-color: rgba(180,83,9,.25); background: rgba(180,83,9,.08)}
.badge.danger{color: var(--danger); border-color: rgba(185,28,28,.25); background: rgba(185,28,28,.08)}
.list{display:flex; flex-direction:column; gap:10px}
.item{
border:1px solid rgba(229,231,235,.9);
background: rgba(255,255,255,.75);
border-radius: 16px;
padding:10px;
display:grid;
grid-template-columns: 1fr 140px;
gap:10px;
align-items:center;
}
@media (max-width:560px){
.item{grid-template-columns:1fr}
}
.item h3{margin:0; font-size:13.5px}
.item p{margin:6px 0 0; color: var(--muted); font-size:12px; line-height:1.35}
.itemActions{display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap}
.kbd{
font-family: var(--mono);
font-size:11px;
padding:2px 6px;
border-radius: 999px;
border:1px solid rgba(229,231,235,.9);
background: rgba(255,255,255,.85);
color: var(--muted);
margin-left:6px;
}
.meter{
display:flex; gap:8px; align-items:center; flex-wrap:wrap;
padding:10px; border-radius: 16px;
border:1px solid rgba(229,231,235,.9);
background: rgba(255,255,255,.65);
}
.meter b{font-size:12.5px}
input[type="range"]{width:100%}
.toast{
position:fixed; left:50%; bottom:18px; transform:translateX(-50%);
background: rgba(31,41,55,.92);
color:#fff;
padding:10px 12px;
border-radius: 999px;
box-shadow: 0 12px 30px rgba(0,0,0,.25);
font-size:12.5px;
opacity:0; pointer-events:none;
transition: opacity .18s ease, transform .18s ease;
}
.toast.show{opacity:1; transform:translateX(-50%) translateY(-2px)}
details{
border:1px solid rgba(229,231,235,.9);
background: rgba(255,255,255,.6);
border-radius: 16px;
padding:10px;
}
details summary{cursor:pointer; font-weight:800; color:var(--ink)}
.muted{color:var(--muted)}
.rightColSticky{
position: sticky;
top: 12px;
align-self: start;
}
@media (max-width: 980px){ .rightColSticky{position: static} }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<h1>特调咖啡饮品设计器 <span class="kbd">本机离线</span></h1>
<p>用「五要素」把创意落到配方:主体元素 / 风味强化 / 质地 / 装饰 / 概念。支持保存、复制、导出 CSV/JSON、导入。</p>
</div>
<div class="topActions">
<span class="pill" id="autosavePill">已启用自动保存</span>
<button class="btnGhost" id="btnNew">新建</button>
<button class="btnGhost" id="btnRandom">来点灵感</button>
<button class="btnPrimary" id="btnSave">保存到配方库</button>
</div>
</header>
<main>
<!-- Left: Editor -->
<section class="card">
<div class="cardHead">
<div class="cardTitle">
<h2>配方编辑</h2>
<p>选择结构 → 添加原料 → 调整平衡 → 写下做法与故事。右侧会实时生成「配方卡」。</p>
</div>
<div class="tabs" role="tablist" aria-label="编辑区标签">
<button class="tab" role="tab" aria-selected="true" data-tab="core">结构</button>
<button class="tab" role="tab" aria-selected="false" data-tab="ingredients">原料</button>
<button class="tab" role="tab" aria-selected="false" data-tab="method">做法</button>
<button class="tab" role="tab" aria-selected="false" data-tab="concept">概念</button>
</div>
</div>
<div class="cardBody" id="tab_core">
<div class="grid2">
<div>
<label>饮品名称</label>
<input id="name" type="text" placeholder="例如:Dream Bath / 梦境泡浴" />
<div class="hint" style="margin-top:6px">
也可以点 <b>来点灵感</b> 自动起名 + 组合方向。
</div>
</div>
<div>
<label>类型</label>
<select id="type">
<option value="cold">冷饮</option>
<option value="hot">热饮</option>
<option value="nitro">氮气/气泡</option>
<option value="dessert">甜品向</option>
<option value="lowabv">低酒精/无酒精</option>
</select>
</div>
</div>
<div class="sep"></div>
<div class="grid2">
<div>
<label>主体(咖啡基底)</label>
<select id="coffeeBase"></select>
</div>
<div>
<label>主体(酒/无酒精基底,可选)</label>
<select id="spiritBase"></select>
</div>
</div>
<div style="margin-top:10px" class="grid2">
<div>
<label>杯型 / 容器</label>
<select id="glass">
<option value="rocks">Rocks / Old Fashioned</option>
<option value="coupe">Coupe / 香槟碟</option>
<option value="martini">Martini / V形杯</option>
<option value="highball">Highball / 长杯</option>
<option value="mug">Mug / 马克杯</option>
<option value="tulip">Tulip / 郁金香杯</option>
<option value="stemless">Stemless / 无脚杯</option>
<option value="bottle">Bottle / 瓶装</option>
</select>
</div>
<div>
<label>目标氛围(用于灵感推荐)</label>
<select id="mood">
<option value="cozy">温暖治愈</option>
<option value="dreamy">梦境奇幻</option>
<option value="clean">清爽干净</option>
<option value="luxury">高端精致</option>
<option value="playful">俏皮有趣</option>
<option value="dark">暗黑深邃</option>
</select>
</div>
</div>
<div class="sep"></div>
<div class="meter">
<div style="flex:1; min-width:220px">
<b>平衡预期</b>
<div class="hint">不是绝对数值,更多是「设计意图」。右侧会提示风险点。</div>
</div>
<div style="flex:1; min-width:220px">
<label>甜度 <span class="muted" id="sweetVal">50</span></label>
<input id="sweet" type="range" min="0" max="100" value="50" />
</div>
<div style="flex:1; min-width:220px">
<label>酸度 <span class="muted" id="acidVal">35</span></label>
<input id="acid" type="range" min="0" max="100" value="35" />
</div>
<div style="flex:1; min-width:220px">
<label>苦度 <span class="muted" id="bitterVal">45</span></label>
<input id="bitter" type="range" min="0" max="100" value="45" />
</div>
<div style="flex:1; min-width:220px">
<label>酒精感 <span class="muted" id="boozyVal">25</span></label>
<input id="boozy" type="range" min="0" max="100" value="25" />
</div>
<div style="flex:1; min-width:220px">
<label>醇厚度 <span class="muted" id="bodyVal">55</span></label>
<input id="body" type="range" min="0" max="100" value="55" />
</div>
</div>
<div style="margin-top:12px" class="row">
<button class="btnGhost" id="btnAutoName">自动起名</button>
<button class="btnGhost" id="btnAutoSuggest">给我 6 个「可能的风味强化」</button>
</div>
<div class="hint" style="margin-top:10px">
提示:你可以把这页当作“导演分镜”——先定类型与平衡,再进入原料堆叠。
</div>
</div>
<div class="cardBody" id="tab_ingredients" style="display:none">
<div class="grid2">
<div>
<label>原料库(按类别)</label>
<select id="libCategory"></select>
</div>
<div>
<label>搜索原料</label>
<input id="libSearch" type="text" placeholder="例如:佛手柑 / 榛子 / 岩兰草 / 椰子…" />
</div>
</div>
<div style="margin-top:10px" class="row">
<div>
<label>选择一个原料</label>
<select id="libItem"></select>
</div>
<div style="max-width:160px">
<label>用量</label>
<input id="amount" type="number" step="0.1" min="0" placeholder="例如 15" />
</div>
<div style="max-width:160px">
<label>单位</label>
<select id="unit">
<option value="ml">ml</option>
<option value="g">g</option>
<option value="dash">dash</option>
<option value="drop">drop</option>
<option value="piece">piece</option>
<option value="spray">spray</option>
</select>
</div>
<div style="max-width:200px">
<label>五要素归类</label>
<select id="role">
<option value="main">主体元素</option>
<option value="boost">风味强化</option>
<option value="texture">质地</option>
<option value="garnish">装饰</option>
</select>
</div>
</div>
<div style="margin-top:10px" class="row">
<button class="btnPrimary" id="btnAddIng">添加到配方</button>
<button class="btnGhost" id="btnQuickAddEsp">+ 浓缩 30ml</button>
<button class="btnGhost" id="btnQuickAddIce">+ 冰 120g</button>
<button class="btnGhost" id="btnClearIngs">清空原料</button>
</div>
<div class="sep"></div>
<div class="row" style="align-items:flex-start">
<div style="flex:1.2">
<h3 style="margin:0 0 8px">当前配方原料</h3>
<div class="hint" style="margin:0 0 10px">点击条目可编辑;支持上移/下移;自动估算总量与 ABV。</div>
<div class="list" id="ingList"></div>
</div>
<div style="flex:.8; min-width:260px">
<details open>
<summary>智能提示(实时)</summary>
<div style="margin-top:10px" id="smartHints" class="hint"></div>
</details>
<div style="margin-top:10px">
<details>
<summary>灵感推荐:与当前风味更“搭”的原料</summary>
<div style="margin-top:10px" id="recommendations" class="chips"></div>
</details>
</div>
<div style="margin-top:10px">
<details>
<summary>可选:ABV 估算说明</summary>
<div class="hint" style="margin-top:10px">
ABV 估算基于:<b>含酒精原料的体积(ml) × 酒精度</b> / <b>总液体估算体积</b>。<br/>
若你用 g / dash / spray,系统会做保守换算(并在提示里标注“估算”)。
</div>
</details>
</div>
</div>
</div>
</div>
<div class="cardBody" id="tab_method" style="display:none">
<div class="grid2">
<div>
<label>制作方式(流程)</label>
<select id="technique">
<option value="shake">Shake / 摇和</option>
<option value="stir">Stir / 搅拌</option>
<option value="build">Build / 直调</option>
<option value="blend">Blend / 搅打</option>
<option value="hotbuild">Hot Build / 热调</option>
<option value="clarify">Clarify / 澄清(奶洗/果胶酶等)</option>
</select>
</div>
<div>
<label>关键控制点</label>
<input id="controlPoints" type="text" placeholder="例如:稀释 18% / 出杯温度 62°C / 先酒后咖…" />
</div>
</div>
<div style="margin-top:10px">
<label>步骤(建议写成可复现 SOP)</label>
<textarea id="steps" placeholder="1) 预冷杯… 2) 加入… 3) …"></textarea>
</div>
<div style="margin-top:10px" class="grid2">
<div>
<label>装饰与呈现(视觉/触感)</label>
<textarea id="presentation" placeholder="例如:南瓜薄片 + 白砂糖火焰 / 喷雾 / 热蜡封口 / 湿润边…"></textarea>
</div>
<div>
<label>闻香入口节奏(体验脚本)</label>
<textarea id="experience" placeholder="例如:先闻喷雾→入口甜→中段咖啡→尾段木质…"></textarea>
</div>
</div>
</div>
<div class="cardBody" id="tab_concept" style="display:none">
<div class="grid2">
<div>
<label>概念关键词(用逗号分隔)</label>
<input id="keywords" type="text" placeholder="例如:梦 / 浴室蒸汽 / 夜礼服 / 低语 / 冬天…" />
</div>
<div>
<label>目标香气家族(用于推荐)</label>
<select id="aromaFamily" multiple size="6" style="height:auto; padding:10px;">
<option value="citrus">柑橘</option>
<option value="floral">花香</option>
<option value="fruity">果香</option>
<option value="nutty">坚果</option>
<option value="chocolate">巧克力/可可</option>
<option value="caramel">焦糖/烘焙</option>
<option value="spice">香料</option>
<option value="herbal">草本</option>
<option value="woody">木质</option>
<option value="smoky">烟熏</option>
<option value="savory">咸鲜/旨味</option>
<option value="dairy">乳脂/奶香</option>
</select>
<div class="hint" style="margin-top:6px">手机上:可用长按/多选(不同机型表现不同)。</div>
</div>
</div>
<div style="margin-top:10px">
<label>故事/一句话 pitch(给评委/客人)</label>
<textarea id="story" placeholder="例如:这杯像把城市的夜拧成一团蒸汽…"></textarea>
</div>
<div style="margin-top:10px" class="row">
<button class="btnGhost" id="btnIdea1">生成一句 Pitch</button>
<button class="btnGhost" id="btnIdea2">生成「命名 + 剧场」</button>
<button class="btnGhost" id="btnIdea3">检查五要素完整度</button>
</div>
<div style="margin-top:12px" class="mini" id="conceptOutput">这里会出现你的一句话 pitch / 命名建议 / 五要素检查结果。</div>
</div>
</section>
<!-- Right: Preview & Library -->
<section class="card rightColSticky">
<div class="cardHead">
<div class="cardTitle">
<h2>配方卡(可复制/导出)</h2>
<p>实时生成。建议你在出品前,把这里当作“最终对外版本”。</p>
</div>
<div class="tabs" role="tablist" aria-label="右侧标签">
<button class="tab" role="tab" aria-selected="true" data-rtab="preview">预览</button>
<button class="tab" role="tab" aria-selected="false" data-rtab="library">配方库</button>
<button class="tab" role="tab" aria-selected="false" data-rtab="io">导入/导出</button>
</div>
</div>
<div class="cardBody" id="rtab_preview">
<div class="row" style="gap:8px; margin-bottom:10px">
<button class="btnGhost" id="btnCopy">复制配方文本</button>
<button class="btnGhost" id="btnExportCSV">导出 CSV</button>
<button class="btnGhost" id="btnExportJSON">导出 JSON</button>
</div>
<div class="row" style="gap:8px; margin-bottom:10px">
<span class="badge" id="badgeABV">ABV:—</span>
<span class="badge" id="badgeVol">总量:—</span>
<span class="badge" id="badgeRisk">提示:—</span>
</div>
<div class="mini" id="preview"></div>
</div>
<div class="cardBody" id="rtab_library" style="display:none">
<div class="row" style="gap:8px">
<input id="libFilter" type="text" placeholder="搜索已保存配方(名称/关键词)" />
<button class="btnGhost" id="btnExportAll">导出全部 JSON</button>
</div>
<div class="sep"></div>
<div class="list" id="savedList"></div>
<div class="hint" style="margin-top:10px">
所有内容只保存在本机浏览器(localStorage)。换手机/清缓存会丢失,建议定期导出 JSON 备份。
</div>
</div>
<div class="cardBody" id="rtab_io" style="display:none">
<div>
<label>导入 JSON(粘贴后导入)</label>
<textarea id="importBox" placeholder='粘贴导出的 JSON(单条或数组)'></textarea>
<div class="row" style="margin-top:10px">
<button class="btnPrimary" id="btnImport">导入到配方库</button>
<button class="btnGhost" id="btnCopyJSON">复制当前配方 JSON</button>
</div>
</div>
<div class="sep"></div>
<details>
<summary>分享给别人:最简单的本地方式</summary>
<div class="hint" style="margin-top:10px">
1) 把 <b>index.html</b> 发给对方(微信/网盘/邮件均可)。<br/>
2) 对方用手机浏览器打开即可使用。<br/>
3) 若想“像 App 一样”添加到桌面:iOS Safari 里点分享 → 添加到主屏幕;安卓 Chrome 里点菜单 → 添加到主屏幕。
</div>
</details>
</div>
</section>
</main>
<div class="toast" id="toast">已复制</div>
</div>
<script>
(function(){
const LS_KEY_DRAFT = "coffee_designer_draft_v1";
const LS_KEY_SAVED = "coffee_designer_saved_v1";
// ---- Ingredient Library (轻量内置,可自行扩展) ----
// tags: aroma family + vibe hints
const LIB = [
// Coffee
{name:"浓缩 Espresso", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["caramel","chocolate","roasty"], defaultAmt:30},
{name:"美式/热水 Hot Water", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["clean"], defaultAmt:60},
{name:"冷萃 Cold Brew", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["clean","fruity"], defaultAmt:60},
{name:"氮气冷萃 Nitro Cold Brew", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["clean","body"], defaultAmt:120},
{name:"浓缩浓缩液 Ristretto", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["caramel","chocolate"], defaultAmt:22},
// Spirits
{name:"威士忌 Whiskey", cat:"酒基底", abv:40, unitHint:"ml", tags:["woody","caramel","smoky"], defaultAmt:30},
{name:"朗姆 Rum", cat:"酒基底", abv:40, unitHint:"ml", tags:["caramel","spice"], defaultAmt:30},
{name:"白朗姆 White Rum", cat:"酒基底", abv:40, unitHint:"ml", tags:["clean","citrus"], defaultAmt:30},
{name:"金酒 Gin", cat:"酒基底", abv:40, unitHint:"ml", tags:["herbal","citrus"], defaultAmt:25},
{name:"白兰地 Brandy/Cognac", cat:"酒基底", abv:40, unitHint:"ml", tags:["fruity","woody"], defaultAmt:25},
{name:"利口酒 Coffee Liqueur", cat:"酒基底", abv:20, unitHint:"ml", tags:["chocolate","caramel"], defaultAmt:20},
{name:"阿玛雷托 Amaretto", cat:"酒基底", abv:28, unitHint:"ml", tags:["nutty","caramel"], defaultAmt:20},
{name:"君度/橙味利口酒 Orange Liqueur", cat:"酒基底", abv:40, unitHint:"ml", tags:["citrus"], defaultAmt:15},
{name:"苦艾/茴香烈酒 Absinthe/Pastis", cat:"酒基底", abv:45, unitHint:"drop", tags:["herbal","spice"], defaultAmt:0.6},
// Sweeteners
{name:"糖浆 Simple Syrup", cat:"甜味", abv:0, unitHint:"ml", tags:["clean"], defaultAmt:10},
{name:"红糖糖浆 Demerara Syrup", cat:"甜味", abv:0, unitHint:"ml", tags:["caramel","roasty"], defaultAmt:10},
{name:"蜂蜜 Honey", cat:"甜味", abv:0, unitHint:"g", tags:["floral"], defaultAmt:8},
{name:"枫糖 Maple", cat:"甜味", abv:0, unitHint:"ml", tags:["caramel","woody"], defaultAmt:10},
{name:"香草糖浆 Vanilla Syrup", cat:"甜味", abv:0, unitHint:"ml", tags:["dairy","caramel","floral"], defaultAmt:8},
{name:"可可糖浆 Cacao Syrup", cat:"甜味", abv:0, unitHint:"ml", tags:["chocolate"], defaultAmt:10},
// Acids / Citrus
{name:"柠檬汁 Lemon", cat:"酸度", abv:0, unitHint:"ml", tags:["citrus","clean"], defaultAmt:10},
{name:"青柠汁 Lime", cat:"酸度", abv:0, unitHint:"ml", tags:["citrus","clean"], defaultAmt:10},
{name:"佛手柑(皮油/喷雾)Bergamot", cat:"酸度", abv:0, unitHint:"spray", tags:["citrus","floral","luxury"], defaultAmt:2},
{name:"苹果酸 Malic", cat:"酸度", abv:0, unitHint:"g", tags:["fruity","clean"], defaultAmt:0.6},
{name:"柠檬酸 Citric", cat:"酸度", abv:0, unitHint:"g", tags:["citrus","clean"], defaultAmt:0.5},
{name:"乳酸 Lactic", cat:"酸度", abv:0, unitHint:"g", tags:["dairy","smooth"], defaultAmt:0.4},
// Aromatics / Botanicals
{name:"玫瑰 Rose", cat:"香气", abv:0, unitHint:"spray", tags:["floral","dreamy","luxury"], defaultAmt:2},
{name:"茉莉 Jasmine", cat:"香气", abv:0, unitHint:"spray", tags:["floral","clean"], defaultAmt:2},
{name:"桂花 Osmanthus", cat:"香气", abv:0, unitHint:"ml", tags:["floral","fruity"], defaultAmt:6},
{name:"迷迭香 Rosemary", cat:"香气", abv:0, unitHint:"piece", tags:["herbal","woody"], defaultAmt:1},
{name:"百里香 Thyme", cat:"香气", abv:0, unitHint:"piece", tags:["herbal"], defaultAmt:1},
{name:"岩兰草 Vetiver", cat:"香气", abv:0, unitHint:"drop", tags:["woody","smoky","dark"], defaultAmt:2},
{name:"柚子 Yuzu", cat:"香气", abv:0, unitHint:"ml", tags:["citrus","fruity","clean"], defaultAmt:8},
{name:"乌龙 Oolong", cat:"香气", abv:0, unitHint:"ml", tags:["floral","woody"], defaultAmt:20},
// Texture / Dairy
{name:"牛奶 Milk", cat:"质地", abv:0, unitHint:"ml", tags:["dairy","body"], defaultAmt:60},
{name:"淡奶油 Cream", cat:"质地", abv:0, unitHint:"ml", tags:["dairy","luxury","body"], defaultAmt:30},
{name:"燕麦奶 Oat", cat:"质地", abv:0, unitHint:"ml", tags:["dairy","body","cozy"], defaultAmt:60},
{name:"椰奶 Coconut", cat:"质地", abv:0, unitHint:"ml", tags:["nutty","dairy","tropical"], defaultAmt:50},
{name:"黄油澄清 Butter (clarified)", cat:"质地", abv:0, unitHint:"g", tags:["dairy","caramel","luxury"], defaultAmt:6},
{name:"蛋白 Egg White", cat:"质地", abv:0, unitHint:"ml", tags:["body","foam"], defaultAmt:25},
{name:"海盐 Salt", cat:"质地", abv:0, unitHint:"pinch", tags:["savory"], defaultAmt:1},
// Spices / Bitters
{name:"安哥仕苦精 Angostura Bitters", cat:"香料/苦味", abv:44.7, unitHint:"dash", tags:["spice","woody"], defaultAmt:2},
{name:"橙味苦精 Orange Bitters", cat:"香料/苦味", abv:28, unitHint:"dash", tags:["citrus","spice"], defaultAmt:2},
{name:"可可苦精 Cacao Bitters", cat:"香料/苦味", abv:35, unitHint:"dash", tags:["chocolate","spice"], defaultAmt:2},
{name:"豆蔻 Cardamom", cat:"香料/苦味", abv:0, unitHint:"drop", tags:["spice","floral"], defaultAmt:2},
{name:"黑胡椒 Black Pepper", cat:"香料/苦味", abv:0, unitHint:"pinch", tags:["spice","savory"], defaultAmt:1},
// Garnish
{name:"柑橘皮 Orange Peel", cat:"装饰", abv:0, unitHint:"piece", tags:["citrus"], defaultAmt:1},
{name:"南瓜薄片 Pumpkin Slice", cat:"装饰", abv:0, unitHint:"piece", tags:["cozy","dessert"], defaultAmt:1},
{name:"可可粉 Cocoa Dust", cat:"装饰", abv:0, unitHint:"pinch", tags:["chocolate"], defaultAmt:1},
{name:"白砂糖火焰 Sugar Flame", cat:"装饰", abv:0, unitHint:"piece", tags:["dramatic","luxury"], defaultAmt:1},
{name:"喷雾(香水式)Aroma Spray", cat:"装饰", abv:0, unitHint:"spray", tags:["luxury","dreamy"], defaultAmt:2},
// Others / Dilution
{name:"冰 Ice", cat:"稀释/温控", abv:0, unitHint:"g", tags:["cold"], defaultAmt:120},
{name:"苏打水 Soda", cat:"稀释/温控", abv:0, unitHint:"ml", tags:["clean"], defaultAmt:60},
{name:"热水 Hot Water", cat:"稀释/温控", abv:0, unitHint:"ml", tags:["hot"], defaultAmt:60},
];
const COFFEE_BASES = [
"浓缩 Espresso","Ristretto","冷萃 Cold Brew","氮气冷萃 Nitro Cold Brew","美式/热水 Hot Water"
];
const SPIRIT_BASES = [
"无","威士忌 Whiskey","朗姆 Rum","白朗姆 White Rum","金酒 Gin","白兰地 Brandy/Cognac","利口酒 Coffee Liqueur","阿玛雷托 Amaretto"
];
// ---- State ----
const defaultDraft = () => ({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()),
name: "",
type: "cold",
coffeeBase: "浓缩 Espresso",
spiritBase: "无",
glass: "rocks",
mood: "dreamy",
technique: "build",
controlPoints: "",
sweet: 50, acid: 35, bitter: 45, boozy: 25, body: 55,
keywords: "",
aromaFamily: [],
story: "",
steps: "",
presentation: "",
experience: "",
ingredients: [
// Start empty; user can quick-add espresso/ice
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
let draft = loadDraft() || defaultDraft();
let saved = loadSaved();
// ---- DOM ----
const $ = (id) => document.getElementById(id);
const el = {
// tabs
tabs: document.querySelectorAll('.tab[role="tab"][data-tab]'),
tabBodies: {
core: $('tab_core'),
ingredients: $('tab_ingredients'),
method: $('tab_method'),
concept: $('tab_concept')
},
rTabs: document.querySelectorAll('.tab[role="tab"][data-rtab]'),
rBodies: {
preview: $('rtab_preview'),
library: $('rtab_library'),
io: $('rtab_io')
},
// inputs
name: $('name'),
type: $('type'),
coffeeBase: $('coffeeBase'),
spiritBase: $('spiritBase'),
glass: $('glass'),
mood: $('mood'),
sweet: $('sweet'), acid: $('acid'), bitter: $('bitter'), boozy: $('boozy'), body: $('body'),
sweetVal: $('sweetVal'), acidVal: $('acidVal'), bitterVal: $('bitterVal'), boozyVal: $('boozyVal'), bodyVal: $('bodyVal'),
// ingredients lib
libCategory: $('libCategory'),
libSearch: $('libSearch'),
libItem: $('libItem'),
amount: $('amount'),
unit: $('unit'),
role: $('role'),
// method
technique: $('technique'),
controlPoints: $('controlPoints'),
steps: $('steps'),
presentation: $('presentation'),
experience: $('experience'),
// concept
keywords: $('keywords'),
aromaFamily: $('aromaFamily'),
story: $('story'),
conceptOutput: $('conceptOutput'),
// list & preview
ingList: $('ingList'),
smartHints: $('smartHints'),
recommendations: $('recommendations'),
preview: $('preview'),
badgeABV: $('badgeABV'),
badgeVol: $('badgeVol'),
badgeRisk: $('badgeRisk'),
// library
libFilter: $('libFilter'),
savedList: $('savedList'),
// IO
importBox: $('importBox'),
// buttons
btnNew: $('btnNew'),
btnRandom: $('btnRandom'),
btnSave: $('btnSave'),
btnAutoName: $('btnAutoName'),
btnAutoSuggest: $('btnAutoSuggest'),
btnAddIng: $('btnAddIng'),
btnQuickAddEsp: $('btnQuickAddEsp'),
btnQuickAddIce: $('btnQuickAddIce'),
btnClearIngs: $('btnClearIngs'),
btnCopy: $('btnCopy'),
btnExportCSV: $('btnExportCSV'),
btnExportJSON: $('btnExportJSON'),
btnExportAll: $('btnExportAll'),
btnImport: $('btnImport'),
btnCopyJSON: $('btnCopyJSON'),
btnIdea1: $('btnIdea1'),
btnIdea2: $('btnIdea2'),
btnIdea3: $('btnIdea3'),
toast: $('toast')
};
// ---- Init selects ----
function initSelects(){
// coffeeBase select
el.coffeeBase.innerHTML = COFFEE_BASES.map(v=>`<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');
el.spiritBase.innerHTML = SPIRIT_BASES.map(v=>`<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');
// ingredient library categories
const cats = Array.from(new Set(LIB.map(x=>x.cat))).sort((a,b)=>a.localeCompare(b,'zh-CN'));
el.libCategory.innerHTML = ['全部类别', ...cats].map(c=>`<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
el.libCategory.value = "全部类别";
refreshLibItems();
}
function refreshLibItems(){
const cat = el.libCategory.value;
const q = (el.libSearch.value || "").trim().toLowerCase();
const list = LIB.filter(x=>{
const inCat = (cat==="全部类别") ? true : x.cat===cat;
const inQ = !q ? true : (x.name.toLowerCase().includes(q) || x.cat.toLowerCase().includes(q) || (x.tags||[]).join(',').toLowerCase().includes(q));
return inCat && inQ;
});
el.libItem.innerHTML = list.map((x,i)=>{
const hint = x.tags?.length ? ` · ${x.tags.slice(0,3).join('/')}` : '';
return `<option value="${escapeHtml(x.name)}">${escapeHtml(x.name)}${escapeHtml(hint)}</option>`;
}).join('');
// auto fill amount/unit from library item
const picked = list[0];
if(picked){
el.amount.value = picked.defaultAmt ?? "";
el.unit.value = normalizeUnit(picked.unitHint || "ml");
}
}
function normalizeUnit(u){
const allowed = new Set(["ml","g","dash","drop","piece","spray"]);
return allowed.has(u) ? u : "ml";
}
// ---- Tabs ----
function setTab(key){
el.tabs.forEach(t=>{
const on = t.dataset.tab===key;
t.setAttribute('aria-selected', on ? 'true':'false');
});
Object.entries(el.tabBodies).forEach(([k,node])=>{
node.style.display = (k===key) ? 'block':'none';
});
}
function setRTab(key){
el.rTabs.forEach(t=>{
const on = t.dataset.rtab===key;
t.setAttribute('aria-selected', on ? 'true':'false');
});
Object.entries(el.rBodies).forEach(([k,node])=>{
node.style.display = (k===key) ? 'block':'none';
});
}
// ---- Bind inputs to state ----
function bind(){
// left tabs
el.tabs.forEach(t=>t.addEventListener('click', ()=>setTab(t.dataset.tab)));
el.rTabs.forEach(t=>t.addEventListener('click', ()=>setRTab(t.dataset.rtab)));
// fields -> draft
const map = [
['name','name'], ['type','type'], ['glass','glass'], ['mood','mood'],
['technique','technique'], ['controlPoints','controlPoints'],
['steps','steps'], ['presentation','presentation'], ['experience','experience'],
['keywords','keywords'], ['story','story']
];
map.forEach(([domKey, stateKey])=>{
el[domKey].addEventListener('input', ()=>{
draft[stateKey] = el[domKey].value;
touch();
});
});
el.coffeeBase.addEventListener('change', ()=>{ draft.coffeeBase = el.coffeeBase.value; touch(); });
el.spiritBase.addEventListener('change', ()=>{ draft.spiritBase = el.spiritBase.value; touch(); });
// sliders
const sliders = [
['sweet','sweet','sweetVal'],
['acid','acid','acidVal'],
['bitter','bitter','bitterVal'],
['boozy','boozy','boozyVal'],
['body','body','bodyVal']
];
sliders.forEach(([id, key, lab])=>{
el[id].addEventListener('input', ()=>{
draft[key] = +el[id].value;
el[lab].textContent = String(draft[key]);
touch();
});
});
// aroma multi-select
el.aromaFamily.addEventListener('change', ()=>{
draft.aromaFamily = Array.from(el.aromaFamily.selectedOptions).map(o=>o.value);
touch();
});
// library filters
el.libCategory.addEventListener('change', refreshLibItems);
el.libSearch.addEventListener('input', refreshLibItems);
el.libItem.addEventListener('change', ()=>{
const item = LIB.find(x=>x.name===el.libItem.value);
if(item){
el.amount.value = item.defaultAmt ?? "";
el.unit.value = normalizeUnit(item.unitHint || "ml");
}
});
// buttons
el.btnNew.addEventListener('click', ()=>{
draft = defaultDraft();
applyDraftToUI();
toast("新建完成");
});
el.btnRandom.addEventListener('click', ()=>{
applyRandomIdea();
toast("已生成灵感组合");
});
el.btnSave.addEventListener('click', saveToLibrary);
el.btnAutoName.addEventListener('click', ()=>{
if(!draft.name.trim()){
draft.name = autoName();
}else{
// append alt name suggestion
draft.name = draft.name.trim() + " / " + autoName();
}
el.name.value = draft.name;
touch();
toast("已生成名称");
});
el.btnAutoSuggest.addEventListener('click', ()=>{
const picks = suggestBoosters(6);
setTab('ingredients');
// show recommended chips in the recommendation panel
renderRecommendations(picks, true);
toast("已生成强化建议");
});
el.btnAddIng.addEventListener('click', ()=>{
const itemName = el.libItem.value;
if(!itemName){ toast("请先选择原料"); return; }
const amt = parseFloat(el.amount.value);
const unit = el.unit.value;
const role = el.role.value;
if(!(amt>0) && !["dash","drop","piece","spray"].includes(unit)){
toast("请输入用量");
return;
}
addIngredient({
name: itemName,
amount: isFinite(amt) ? amt : null,
unit,
role
});
toast("已添加原料");
});
el.btnQuickAddEsp.addEventListener('click', ()=>{
addIngredient({name:"浓缩 Espresso", amount:30, unit:"ml", role:"main"});
toast("已添加:浓缩 30ml");
});
el.btnQuickAddIce.addEventListener('click', ()=>{
addIngredient({name:"冰 Ice", amount:120, unit:"g", role:"texture"});
toast("已添加:冰 120g");
});
el.btnClearIngs.addEventListener('click', ()=>{
draft.ingredients = [];
touch();
toast("已清空原料");
});
el.btnCopy.addEventListener('click', async ()=>{
const text = buildRecipeText(draft);
await copyToClipboard(text);
toast("已复制配方文本");
});
el.btnExportCSV.addEventListener('click', ()=>exportCSV(draft));
el.btnExportJSON.addEventListener('click', ()=>exportJSON(draft));
el.btnExportAll.addEventListener('click', ()=>{
exportAllJSON(saved);
});
el.btnImport.addEventListener('click', ()=>{
const raw = el.importBox.value.trim();
if(!raw){ toast("请粘贴 JSON"); return; }
try{
const data = JSON.parse(raw);
const arr = Array.isArray(data) ? data : [data];
const normalized = arr
.filter(Boolean)
.map(x=>normalizeRecipe(x))
.filter(x=>x && x.id && x.name!=null);
if(!normalized.length){ toast("没有可导入的配方"); return; }
// merge by id (upsert)
const map = new Map(saved.map(r=>[r.id,r]));
normalized.forEach(r=>map.set(r.id, r));
saved = Array.from(map.values()).sort((a,b)=>(b.updatedAt||"").localeCompare(a.updatedAt||""));
saveSaved(saved);
renderSavedList();
toast("导入完成");
setRTab('library');
}catch(e){
toast("JSON 解析失败");
}
});
el.btnCopyJSON.addEventListener('click', async ()=>{
const json = JSON.stringify(draft, null, 2);
await copyToClipboard(json);
toast("已复制当前配方 JSON");
});
el.libFilter.addEventListener('input', renderSavedList);
el.btnIdea1.addEventListener('click', ()=>{
const out = genPitch();
el.conceptOutput.textContent = out;
toast("已生成 Pitch");
});
el.btnIdea2.addEventListener('click', ()=>{
const out = genStageName();
el.conceptOutput.textContent = out;
toast("已生成命名/剧场");
});
el.btnIdea3.addEventListener('click', ()=>{
const out = checkFiveElements();
el.conceptOutput.textContent = out;
toast("已检查五要素");
});
// keyboard shortcut (desktop)
window.addEventListener('keydown', (e)=>{
if((e.ctrlKey || e.metaKey) && e.key.toLowerCase()==='s'){
e.preventDefault();
saveToLibrary();
}
});
}
// ---- Ingredient operations ----
function addIngredient(ing){
const libItem = LIB.find(x=>x.name===ing.name);
const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()+Math.random());
draft.ingredients.push({
id,
name: ing.name,
amount: (ing.amount==null ? (libItem?.defaultAmt ?? null) : ing.amount),
unit: ing.unit || normalizeUnit(libItem?.unitHint || "ml"),
role: ing.role || "boost",
abv: libItem?.abv ?? 0,
tags: libItem?.tags ?? []
});
touch();
}
function updateIngredient(id, patch){
const i = draft.ingredients.findIndex(x=>x.id===id);
if(i<0) return;
draft.ingredients[i] = {...draft.ingredients[i], ...patch};
touch();
}
function removeIngredient(id){
draft.ingredients = draft.ingredients.filter(x=>x.id!==id);
touch();
}
function moveIngredient(id, dir){
const idx = draft.ingredients.findIndex(x=>x.id===id);
if(idx<0) return;
const to = idx + dir;
if(to<0 || to>=draft.ingredients.length) return;
const arr = draft.ingredients.slice();
const [item] = arr.splice(idx,1);
arr.splice(to,0,item);
draft.ingredients = arr;
touch();
}
// ---- Computations ----
function estimateVolumeML(){
// Convert amount+unit to rough ml; mark if estimated.
let ml = 0;
let estimated = false;
for(const ing of draft.ingredients){
const a = (ing.amount==null ? 0 : Number(ing.amount));
const u = ing.unit;
if(u==="ml"){ ml += a; }
else if(u==="g"){ ml += a; estimated = true; } // assume 1g ~ 1ml
else if(u==="dash"){ ml += a*0.9; estimated = true; }
else if(u==="drop"){ ml += a*0.05; estimated = true; }
else if(u==="spray"){ ml += a*0.15; estimated = true; }
else if(u==="piece"){ ml += a*2; estimated = true; }
else { estimated = true; }
}
return {ml, estimated};
}
function estimateABV(){
// ABV = sum(alcohol_ml * abv) / total_ml
const vol = estimateVolumeML();
const total = Math.max(vol.ml, 0.0001);
let alcoholPureML = 0;
let estimated = vol.estimated;
for(const ing of draft.ingredients){
const abv = Number(ing.abv||0) / 100;
if(abv<=0) continue;
// estimate ingredient volume in ml
const a = (ing.amount==null ? 0 : Number(ing.amount));
let v = 0;
if(ing.unit==="ml") v = a;
else if(ing.unit==="g"){ v = a; estimated = true; }
else if(ing.unit==="dash"){ v = a*0.9; estimated = true; }
else if(ing.unit==="drop"){ v = a*0.05; estimated = true; }
else if(ing.unit==="spray"){ v = a*0.15; estimated = true; }
else if(ing.unit==="piece"){ v = a*2; estimated = true; }
else { estimated = true; }
alcoholPureML += v * abv;
}
const abvPct = (alcoholPureML / total) * 100;
if(!isFinite(abvPct) || abvPct<0) return {abv:0, estimated:true};
return {abv: abvPct, estimated};
}
function riskHints(){
const issues = [];
const hasCoffee = draft.ingredients.some(x=>x.name.includes("浓缩") || x.name.includes("冷萃") || x.name.includes("Coffee") || x.name.includes("Espresso"));
if(!hasCoffee) issues.push("还没看到咖啡主体:建议至少加入 Espresso / Cold Brew / Coffee Liqueur 等。");
const acid = draft.acid, sweet = draft.sweet, bitter = draft.bitter, body = draft.body;
const hasAcidIng = draft.ingredients.some(x=>x.cat==="酸度") || draft.ingredients.some(x=>/柠檬|青柠|酸/.test(x.name));
const hasSweetIng = draft.ingredients.some(x=>/糖浆|蜂蜜|枫糖|糖/.test(x.name));
const hasTexture = draft.ingredients.some(x=>x.role==="texture" || /奶|Cream|蛋白|黄油/.test(x.name));
if(acid>65 && !hasAcidIng) issues.push("你想要更高酸度,但原料里没有明显酸源(柠檬/苹果酸/柠檬酸等)。");
if(sweet>65 && !hasSweetIng) issues.push("你想要更高甜度,但原料里没有甜味支撑(糖浆/蜂蜜/利口酒等)。");
if(body>65 && !hasTexture) issues.push("你想要更高醇厚度,但原料里缺少质地支撑(奶/奶油/蛋白/黄油等)。");
if(bitter>70 && sweet<35) issues.push("苦度高 + 甜度偏低:可能显得尖锐。可考虑增加糖/乳脂/可可类来圆润。");
const abv = estimateABV();
if(abv.abv>18 && draft.type==="hot") issues.push("热饮 ABV 偏高:注意酒精挥发与刺感,可用奶脂/蜂蜜/香料拉回平衡。");
if(abv.abv>25) issues.push("ABV > 25%:更像短饮或餐后酒,稀释/出杯量/饮用节奏要更清晰。");
// five elements completeness
const roles = new Set(draft.ingredients.map(x=>x.role));
if(!roles.has("boost")) issues.push("风味强化缺位:加一个“桥接香气/主香”会更完整(柑橘/花香/坚果/木质等)。");
if(!roles.has("garnish")) issues.push("装饰缺位:建议至少一个视觉点(皮油、粉、薄片、喷雾、边口等)。");
return issues;
}
// ---- Suggestions ----
function selectedTags(){
const tags = new Set();
// from selected aroma families
(draft.aromaFamily||[]).forEach(t=>tags.add(t));
// from ingredients
draft.ingredients.forEach(ing=>(ing.tags||[]).forEach(t=>tags.add(t)));
// mood mapping
const moodMap = {
cozy:["caramel","dairy","nutty"],
dreamy:["floral","citrus","fruity"],
clean:["citrus","herbal","clean"],
luxury:["floral","woody","dairy"],
playful:["fruity","citrus","caramel"],
dark:["woody","smoky","chocolate"]
};
(moodMap[draft.mood]||[]).forEach(t=>tags.add(t));
return Array.from(tags);
}
function suggestBoosters(n=6){
const tags = selectedTags();
const used = new Set(draft.ingredients.map(x=>x.name));
// Score library by overlap
const candidates = LIB
.filter(x=>!used.has(x.name))
.filter(x=>["甜味","酸度","香气","香料/苦味","质地","装饰"].includes(x.cat))
.map(x=>{
const overlap = (x.tags||[]).filter(t=>tags.includes(t)).length;
const moodBias = (x.tags||[]).includes(draft.mood) ? 1 : 0;
const score = overlap*3 + moodBias;
return {...x, score};
})
.sort((a,b)=>b.score-a.score || a.name.localeCompare(b.name,'zh-CN'));
// Keep top and diversify categories
const out = [];
const catCount = new Map();
for(const c of candidates){
const k = c.cat;
const ct = catCount.get(k)||0;
if(ct>=3) continue;
if(c.score<=0 && out.length>=Math.ceil(n/2)) break;
out.push(c);
catCount.set(k, ct+1);
if(out.length>=n) break;
}
return out;
}
function renderRecommendations(picks = suggestBoosters(6), forceOpen=false){
el.recommendations.innerHTML = "";
picks.forEach(x=>{
const btn = document.createElement('button');
btn.className = "chip";
btn.type = "button";
btn.innerHTML = `${escapeHtml(x.name)} <small>${escapeHtml(x.cat)}</small>`;
btn.addEventListener('click', ()=>{
el.libCategory.value = x.cat;
el.libSearch.value = "";
refreshLibItems();
el.libItem.value = x.name;
const item = LIB.find(i=>i.name===x.name);
if(item){
el.amount.value = item.defaultAmt ?? "";
el.unit.value = normalizeUnit(item.unitHint || "ml");
}
// guess role
const roleGuess = (x.cat==="装饰") ? "garnish" : (x.cat==="质地" ? "texture" : "boost");
el.role.value = roleGuess;
setTab("ingredients");
toast("已定位到原料");
});
el.recommendations.appendChild(btn);
});
}
function applyRandomIdea(){
// random mood, bases, plus 3 boosters
const moods = ["cozy","dreamy","clean","luxury","playful","dark"];
draft.mood = moods[Math.floor(Math.random()*moods.length)];
draft.type = (Math.random()<0.72) ? "cold" : (Math.random()<0.5 ? "hot" : "dessert");
draft.coffeeBase = COFFEE_BASES[Math.floor(Math.random()*COFFEE_BASES.length)];
draft.spiritBase = SPIRIT_BASES[Math.floor(Math.random()*SPIRIT_BASES.length)];
if(draft.spiritBase==="无") draft.type = (Math.random()<0.6) ? "lowabv" : draft.type;
draft.sweet = randAround(draft.type==="dessert" ? 70 : 50, 20);
draft.acid = randAround(draft.mood==="clean" ? 60 : 35, 22);
draft.bitter = randAround(draft.mood==="dark" ? 70 : 45, 18);
draft.boozy = randAround(draft.spiritBase==="无" ? 10 : 35, 22);
draft.body = randAround(draft.mood==="cozy" ? 70 : 55, 18);
if(!draft.name.trim()) draft.name = autoName();
// reset ingredients lightly
draft.ingredients = [];
// add coffee base
if(draft.coffeeBase.includes("浓缩")) addIngredient({name:"浓缩 Espresso", amount:30, unit:"ml", role:"main"});
else if(draft.coffeeBase.includes("冷萃")) addIngredient({name:"冷萃 Cold Brew", amount:60, unit:"ml", role:"main"});
else addIngredient({name:"美式/热水 Hot Water", amount:60, unit:"ml", role:"main"});
// add spirit base if any
if(draft.spiritBase!=="无"){
const libName = draft.spiritBase;
addIngredient({name: libName, amount: (LIB.find(x=>x.name===libName)?.defaultAmt ?? 25), unit:"ml", role:"main"});
}
// add 3 boosters
const boosters = suggestBoosters(3);
boosters.forEach(b=>{
const roleGuess = (b.cat==="装饰") ? "garnish" : (b.cat==="质地" ? "texture" : "boost");
addIngredient({name:b.name, amount:b.defaultAmt ?? 6, unit: normalizeUnit(b.unitHint||"ml"), role: roleGuess});
});
// add ice for cold
if(draft.type==="cold" || draft.type==="nitro") addIngredient({name:"冰 Ice", amount:120, unit:"g", role:"texture"});
applyDraftToUI();
touch();
}
function randAround(center, spread){
const v = Math.round(center + (Math.random()*2-1)*spread);
return Math.max(0, Math.min(100, v));
}
// ---- Naming & Concept helpers ----
function autoName(){
const moodWords = {
cozy:["Warm Bath","Chestnut Steam","Soft Towel","Amber Room","Sunday Robe"],
dreamy:["Dream Bath","Moon Foam","Inception Mist","Cloud Curtain","Lucid Drip"],
clean:["Citrus Rinse","Clear Tile","Cold Shower","White Steam","Bright Basin"],
luxury:["Velvet Bath","Silk Mirror","Gold Steam","Opera Robe","Perfume Marble"],
playful:["Bubble Pop","Foam Party","Splash!","Soap Candy","Tiny Whirlpool"],
dark:["Midnight Tub","Black Tile","Smoke Mirror","Deep Steam","Shadow Rinse"]
};
const key = draft.mood || "dreamy";
const pool = moodWords[key] || moodWords.dreamy;
const w = pool[Math.floor(Math.random()*pool.length)];
// add a taste anchor
const tags = selectedTags();
const anchor = tags.includes("citrus") ? "Bergamot" :
tags.includes("nutty") ? "Almond" :
tags.includes("chocolate") ? "Cacao" :
tags.includes("woody") ? "Vetiver" :
tags.includes("floral") ? "Rose" :
"Coffee";
const style = draft.type==="hot" ? "Hot" : (draft.type==="dessert" ? "Dessert" : "Cold");
return `${w} · ${anchor} · ${style}`;
}
function genPitch(){
const keyw = (draft.keywords||"").split(/[,,]/).map(s=>s.trim()).filter(Boolean).slice(0,4);
const kw = keyw.length ? keyw.join(" / ") : "一场有温度的想象";
const abv = estimateABV();
const abvText = (abv.abv>0.5) ? `(ABV 约 ${abv.abv.toFixed(1)}%${abv.estimated?"*":""})` : "(可做无酒精版本)";
const topAroma = (draft.aromaFamily||[]).slice(0,3);
const aromaText = topAroma.length ? `主香走向:${topAroma.join("、")}` : "主香走向:由你选择的强化原料决定";
const base = `${draft.coffeeBase}${draft.spiritBase!=="无" ? " × "+draft.spiritBase : ""}`;
return `一句话 Pitch:
这杯以「${base}」为骨架,把「${kw}」做成入口的节奏:先闻到香气,再落到咖啡的深处,最后收在干净的尾韵。${aromaText} ${abvText}`;
}
function genStageName(){
const name1 = autoName();
const name2 = autoName();
return `命名候选:
1) ${name1}
2) ${name2}
剧场一句:
“把灯光压低,让杯口的第一口香气先登场;咖啡随后出现,像幕布拉开;最后的余韵,是你走出房间时还留在围巾上的那点气味。”`;
}
function checkFiveElements(){
const roles = {main:0, boost:0, texture:0, garnish:0};
draft.ingredients.forEach(x=>{ if(roles[x.role]!=null) roles[x.role]++; });
const missing = [];
if(roles.main===0) missing.push("主体元素");
if(roles.boost===0) missing.push("风味强化");
if(roles.texture===0) missing.push("质地");
if(roles.garnish===0) missing.push("装饰");
const conceptOk = (draft.story||"").trim().length>0 || (draft.keywords||"").trim().length>0;
const score = (roles.main>0) + (roles.boost>0) + (roles.texture>0) + (roles.garnish>0) + (conceptOk?1:0);
const lines = [];
lines.push(`五要素检查:${score}/5`);
lines.push(`- 主体元素:${roles.main} 项`);
lines.push(`- 风味强化:${roles.boost} 项`);
lines.push(`- 质地:${roles.texture} 项`);
lines.push(`- 装饰:${roles.garnish} 项`);
lines.push(`- 概念:${conceptOk ? "已填写" : "缺失(建议写一句话 pitch)"}`);
if(missing.length){
lines.push(`\n缺失项:${missing.join("、")}。`);
lines.push(`建议:至少补齐 1 个“桥接强化”(柑橘/花香/坚果/木质之一)+ 1 个“视觉点”。`);
}else{
lines.push(`\n完整度不错:可以开始精修“比例与口感曲线”。`);
}
return lines.join("\n");
}
// ---- Renderers ----
function applyDraftToUI(){
el.name.value = draft.name || "";
el.type.value = draft.type || "cold";
el.coffeeBase.value = draft.coffeeBase || "浓缩 Espresso";
el.spiritBase.value = draft.spiritBase || "无";
el.glass.value = draft.glass || "rocks";
el.mood.value = draft.mood || "dreamy";
el.sweet.value = draft.sweet ?? 50; el.sweetVal.textContent = String(el.sweet.value);
el.acid.value = draft.acid ?? 35; el.acidVal.textContent = String(el.acid.value);
el.bitter.value = draft.bitter ?? 45; el.bitterVal.textContent = String(el.bitter.value);
el.boozy.value = draft.boozy ?? 25; el.boozyVal.textContent = String(el.boozy.value);
el.body.value = draft.body ?? 55; el.bodyVal.textContent = String(el.body.value);
el.technique.value = draft.technique || "build";
el.controlPoints.value = draft.controlPoints || "";
el.steps.value = draft.steps || "";
el.presentation.value = draft.presentation || "";
el.experience.value = draft.experience || "";
el.keywords.value = draft.keywords || "";
el.story.value = draft.story || "";
// aromaFamily
Array.from(el.aromaFamily.options).forEach(o=>{
o.selected = (draft.aromaFamily||[]).includes(o.value);
});
renderIngredientList();
renderPreview();
renderSavedList();
renderRecommendations();
}
function renderIngredientList(){
el.ingList.innerHTML = "";
if(!draft.ingredients.length){
el.ingList.innerHTML = `<div class="hint">还没有原料。你可以点 <b>+ 浓缩 30ml</b> 或从原料库添加。</div>`;
return;
}
draft.ingredients.forEach((ing, idx)=>{
const div = document.createElement('div');
div.className = "item";
const roleLabel = roleToLabel(ing.role);
const amtText = formatAmount(ing.amount, ing.unit);
const abv = ing.abv ? ` · ${ing.abv}%` : "";
div.innerHTML = `
<div>
<h3>${escapeHtml(ing.name)} <span class="kbd">${escapeHtml(roleLabel)}</span></h3>
<p>${escapeHtml(amtText)}${escapeHtml(abv)}<br/><span class="muted">${escapeHtml((ing.tags||[]).slice(0,6).join(" / "))}</span></p>
</div>
<div class="itemActions">
<button class="btnGhost" data-act="up">上移</button>
<button class="btnGhost" data-act="down">下移</button>
<button class="btnGhost" data-act="edit">编辑</button>
<button class="btnDanger" data-act="del">删除</button>
</div>
`;
div.querySelector('[data-act="up"]').addEventListener('click', ()=>moveIngredient(ing.id, -1));
div.querySelector('[data-act="down"]').addEventListener('click', ()=>moveIngredient(ing.id, +1));
div.querySelector('[data-act="del"]').addEventListener('click', ()=>removeIngredient(ing.id));
div.querySelector('[data-act="edit"]').addEventListener('click', ()=>openEditIngredient(ing));
el.ingList.appendChild(div);
});
}
function openEditIngredient(ing){
// lightweight prompt-based editor (works well on mobile)
const amount = prompt(`编辑用量(数字,当前:${ing.amount ?? ""})`, String(ing.amount ?? ""));
if(amount===null) return;
const num = parseFloat(amount);
if(isFinite(num)) updateIngredient(ing.id, {amount: num});
const unit = prompt(`编辑单位(ml/g/dash/drop/piece/spray,当前:${ing.unit})`, ing.unit);
if(unit===null) return;
const u = normalizeUnit(unit.trim());
updateIngredient(ing.id, {unit: u});
const role = prompt(`编辑归类(main/boost/texture/garnish,当前:${ing.role})`, ing.role);
if(role===null) return;
const r = ["main","boost","texture","garnish"].includes(role.trim()) ? role.trim() : ing.role;
updateIngredient(ing.id, {role: r});
}
function roleToLabel(r){
return r==="main"?"主体元素": r==="boost"?"风味强化": r==="texture"?"质地": r==="garnish"?"装饰":"—";
}
function formatAmount(a,u){
const amt = (a==null || !isFinite(Number(a))) ? "" : Number(a).toString();
return amt ? `${amt} ${u}` : `${u}`;
}
function renderPreview(){
const abv = estimateABV();
const vol = estimateVolumeML();
const issues = riskHints();
// badges
el.badgeABV.textContent = `ABV:${(abv.abv>0.5 ? abv.abv.toFixed(1)+'%' : '—')}${abv.estimated && abv.abv>0.5 ? "*" : ""}`;
el.badgeABV.className = "badge " + (abv.abv>18 ? "warn" : "good");
el.badgeVol.textContent = `总量:${vol.ml>1 ? Math.round(vol.ml)+' ml' : '—'}${vol.estimated && vol.ml>1 ? "*" : ""}`;
el.badgeVol.className = "badge " + (vol.ml>220 ? "warn" : "good");
const riskLevel = issues.length>=3 ? "danger" : (issues.length>=1 ? "warn" : "good");
el.badgeRisk.textContent = `提示:${issues.length ? issues.length+" 条" : "OK"}`;
el.badgeRisk.className = "badge " + riskLevel;
// smart hints block
el.smartHints.innerHTML = (issues.length ? ("• " + issues.map(escapeHtml).join("<br/>• ")) : "目前结构很顺,下一步可以去精修“步骤与呈现脚本”。");
// preview text
el.preview.textContent = buildRecipeText(draft);
// recommendations
renderRecommendations();
}
function buildRecipeText(r){
const abv = estimateABV();
const vol = estimateVolumeML();
const lines = [];
lines.push(`${r.name?.trim() ? r.name.trim() : "(未命名配方)"}`);
lines.push(`类型:${typeLabel(r.type)} | 杯型:${glassLabel(r.glass)} | 氛围:${moodLabel(r.mood)}`);
lines.push(`主体:${r.coffeeBase}${r.spiritBase && r.spiritBase!=="无" ? " × "+r.spiritBase : ""}`);
lines.push(`平衡意图:甜${r.sweet}/酸${r.acid}/苦${r.bitter}/酒精感${r.boozy}/醇厚${r.body}`);
if(vol.ml>1 || abv.abv>0.5){
const meta = [];
if(vol.ml>1) meta.push(`总量≈${Math.round(vol.ml)}ml${vol.estimated?"*":""}`);
if(abv.abv>0.5) meta.push(`ABV≈${abv.abv.toFixed(1)}%${abv.estimated?"*":""}`);
lines.push(meta.join(" | "));
}
lines.push("");
lines.push("【五要素】");
const grouped = groupByRole(r.ingredients || []);
lines.push(`主体元素:${formatGroup(grouped.main)}`);
lines.push(`风味强化:${formatGroup(grouped.boost)}`);
lines.push(`质地:${formatGroup(grouped.texture)}`);
lines.push(`装饰:${formatGroup(grouped.garnish)}`);
if(r.technique || r.controlPoints || r.steps){
lines.push("");
lines.push("【做法】");
if(r.technique) lines.push(`方式:${techLabel(r.technique)}${r.controlPoints?.trim() ? " | 关键点:"+r.controlPoints.trim() : ""}`);
if(r.steps?.trim()) lines.push(r.steps.trim());
}
if(r.presentation?.trim() || r.experience?.trim()){
lines.push("");
lines.push("【呈现/体验】");
if(r.presentation?.trim()) lines.push("呈现:" + r.presentation.trim());
if(r.experience?.trim()) lines.push("节奏:" + r.experience.trim());
}
if((r.keywords||"").trim() || (r.story||"").trim()){
lines.push("");
lines.push("【概念】");
if((r.keywords||"").trim()) lines.push("关键词:" + r.keywords.trim());
if((r.story||"").trim()) lines.push("一句话:" + r.story.trim());
}
lines.push("");
lines.push(`* 说明:带 * 为估算(含 g/dash/drop/spray 等换算)。`);
return lines.join("\n");
}
function groupByRole(ings){
const g = {main:[], boost:[], texture:[], garnish:[]};
(ings||[]).forEach(x=>{
const k = g[x.role] ? x.role : "boost";
g[k].push(x);
});
return g;
}
function formatGroup(arr){
if(!arr || !arr.length) return "—";
return arr.map(x=>{
const amt = (x.amount==null || !isFinite(Number(x.amount))) ? "" : `${Number(x.amount)}${x.unit}`;
return amt ? `${x.name} ${amt}` : `${x.name}`;
}).join(";");
}
function typeLabel(v){
return ({cold:"冷饮",hot:"热饮",nitro:"氮气/气泡",dessert:"甜品向",lowabv:"低酒精/无酒精"})[v] || v;
}
function glassLabel(v){
return ({rocks:"Rocks",coupe:"Coupe",martini:"Martini",highball:"Highball",mug:"Mug",tulip:"Tulip",stemless:"Stemless",bottle:"Bottle"})[v] || v;
}
function moodLabel(v){
return ({cozy:"温暖治愈",dreamy:"梦境奇幻",clean:"清爽干净",luxury:"高端精致",playful:"俏皮有趣",dark:"暗黑深邃"})[v] || v;
}
function techLabel(v){
return ({shake:"Shake",stir:"Stir",build:"Build",blend:"Blend",hotbuild:"Hot Build",clarify:"Clarify"})[v] || v;
}
// ---- Library (save/load) ----
function saveToLibrary(){
const recipe = normalizeRecipe({...draft});
if(!recipe.name.trim()) recipe.name = autoName();
recipe.updatedAt = new Date().toISOString();
// upsert by id
const idx = saved.findIndex(x=>x.id===recipe.id);
if(idx>=0) saved[idx] = recipe; else saved.unshift(recipe);
// keep recent
saved = saved.sort((a,b)=>(b.updatedAt||"").localeCompare(a.updatedAt||""));
saveSaved(saved);
// also update draft
draft = recipe;
saveDraft(draft);
renderSavedList();
renderPreview();
el.name.value = draft.name;
toast("已保存到配方库(⌘/Ctrl + S 也可保存)");
}
function renderSavedList(){
const q = (el.libFilter.value || "").trim().toLowerCase();
const list = saved.filter(r=>{
if(!q) return true;
const hay = `${r.name||""} ${r.keywords||""} ${r.story||""}`.toLowerCase();
return hay.includes(q);
});
el.savedList.innerHTML = "";
if(!list.length){
el.savedList.innerHTML = `<div class="hint">暂无匹配配方。你可以先保存当前配方。</div>`;
return;
}
list.forEach(r=>{
const div = document.createElement('div');
div.className = "item";
const abv = estimateABVFor(r);
const vol = estimateVolumeFor(r);
const meta = [];
if(vol.ml>1) meta.push(`≈${Math.round(vol.ml)}ml${vol.estimated?"*":""}`);
if(abv.abv>0.5) meta.push(`ABV≈${abv.abv.toFixed(1)}%${abv.estimated?"*":""}`);
const metaText = meta.length ? meta.join(" · ") : "—";
div.innerHTML = `
<div>
<h3>${escapeHtml(r.name || "(未命名)")}</h3>
<p>${escapeHtml(typeLabel(r.type))} | ${escapeHtml(metaText)}<br/>
<span class="muted">${escapeHtml((r.keywords||"").slice(0,60))}${(r.keywords||"").length>60?"…":""}</span></p>
</div>
<div class="itemActions">
<button class="btnGhost" data-act="load">载入</button>
<button class="btnGhost" data-act="dup">复制</button>
<button class="btnDanger" data-act="del">删除</button>
</div>
`;
div.querySelector('[data-act="load"]').addEventListener('click', ()=>{
draft = normalizeRecipe(r);
saveDraft(draft);
applyDraftToUI();
setRTab('preview');
toast("已载入配方");
});
div.querySelector('[data-act="dup"]').addEventListener('click', ()=>{
const copy = normalizeRecipe({...r});
copy.id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now());
copy.name = (copy.name || "Untitled") + " (Copy)";
copy.updatedAt = new Date().toISOString();
saved.unshift(copy);
saveSaved(saved);
renderSavedList();
toast("已复制到配方库");
});
div.querySelector('[data-act="del"]').addEventListener('click', ()=>{
if(confirm(`删除配方「${r.name||"(未命名)"}」?此操作不可撤销。`)){
saved = saved.filter(x=>x.id!==r.id);
saveSaved(saved);
renderSavedList();
toast("已删除");
}
});
el.savedList.appendChild(div);
});
}
function normalizeRecipe(r){
const base = defaultDraft();
const out = {...base, ...r};
out.ingredients = Array.isArray(r.ingredients) ? r.ingredients.map(ing=>{
const libItem = LIB.find(x=>x.name===ing.name);
return {
id: ing.id || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()+Math.random())),
name: ing.name || "Unknown",
amount: (ing.amount==null || ing.amount==="") ? null : Number(ing.amount),
unit: normalizeUnit(ing.unit || libItem?.unitHint || "ml"),
role: ["main","boost","texture","garnish"].includes(ing.role) ? ing.role : "boost",
abv: (ing.abv!=null ? Number(ing.abv) : (libItem?.abv ?? 0)),
tags: Array.isArray(ing.tags) ? ing.tags : (libItem?.tags ?? [])
};
}) : [];
out.aromaFamily = Array.isArray(r.aromaFamily) ? r.aromaFamily : [];
out.updatedAt = r.updatedAt || new Date().toISOString();
return out;
}
// compute for saved items without touching draft
function estimateVolumeFor(r){
let ml=0, estimated=false;
for(const ing of (r.ingredients||[])){
const a = (ing.amount==null ? 0 : Number(ing.amount));
const u = ing.unit;
if(u==="ml"){ ml += a; }
else if(u==="g"){ ml += a; estimated = true; }
else if(u==="dash"){ ml += a*0.9; estimated = true; }
else if(u==="drop"){ ml += a*0.05; estimated = true; }
else if(u==="spray"){ ml += a*0.15; estimated = true; }
else if(u==="piece"){ ml += a*2; estimated = true; }
else { estimated = true; }
}
return {ml, estimated};
}
function estimateABVFor(r){
const vol = estimateVolumeFor(r);
const total = Math.max(vol.ml, 0.0001);
let pure=0, estimated=vol.estimated;
for(const ing of (r.ingredients||[])){
const abv = Number(ing.abv||0)/100;
if(abv<=0) continue;
const a=(ing.amount==null?0:Number(ing.amount));
let v=0;
if(ing.unit==="ml") v=a;
else if(ing.unit==="g"){ v=a; estimated=true; }
else if(ing.unit==="dash"){ v=a*0.9; estimated=true; }
else if(ing.unit==="drop"){ v=a*0.05; estimated=true; }
else if(ing.unit==="spray"){ v=a*0.15; estimated=true; }
else if(ing.unit==="piece"){ v=a*2; estimated=true; }
else { estimated=true; }
pure += v*abv;
}
return {abv: (pure/total)*100, estimated};
}
// ---- Export / Import helpers ----
function exportJSON(r){
const blob = new Blob([JSON.stringify(r, null, 2)], {type:"application/json;charset=utf-8"});
downloadBlob(blob, `${safeFilename(r.name||"recipe")}.json`);
toast("已导出 JSON");
}
function exportAllJSON(list){
const blob = new Blob([JSON.stringify(list, null, 2)], {type:"application/json;charset=utf-8"});
downloadBlob(blob, `coffee-recipes-${new Date().toISOString().slice(0,10)}.json`);
toast("已导出全部 JSON");
}
function exportCSV(r){
const header = ["name","role","ingredient","amount","unit","abv"];
const rows = (r.ingredients||[]).map(x=>[
csvEscape(r.name||""),
csvEscape(roleToLabel(x.role)),
csvEscape(x.name||""),
(x.amount==null ? "" : x.amount),
x.unit||"",
(x.abv==null ? "" : x.abv)
].join(","));
const metaLines = [
["# 类型", typeLabel(r.type)].join(","),
["# 杯型", glassLabel(r.glass)].join(","),
["# 主体", `${r.coffeeBase}${r.spiritBase!=="无" ? " x "+r.spiritBase : ""}`].join(","),
["# 平衡", `甜${r.sweet}/酸${r.acid}/苦${r.bitter}/酒精感${r.boozy}/醇厚${r.body}`].join(",")
];
const csv = [metaLines.join("\n"), header.join(","), ...rows].join("\n");
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
downloadBlob(blob, `${safeFilename(r.name||"recipe")}.csv`);
toast("已导出 CSV");
}
function downloadBlob(blob, filename){
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(()=>{
URL.revokeObjectURL(url);
a.remove();
}, 400);
}
function csvEscape(s){
s = String(s ?? "");
if(/[",\n]/.test(s)) return `"${s.replace(/"/g,'""')}"`;
return s;
}
function safeFilename(s){
return String(s).trim().replace(/[\\/:*?"<>|]+/g,"-").slice(0,60) || "recipe";
}
// ---- Persistence ----
function touch(){
draft.updatedAt = new Date().toISOString();
saveDraft(draft);
renderIngredientList();
renderPreview();
}
function loadDraft(){
try{
const raw = localStorage.getItem(LS_KEY_DRAFT);
if(!raw) return null;
return normalizeRecipe(JSON.parse(raw));
}catch{ return null; }
}
function saveDraft(obj){
try{ localStorage.setItem(LS_KEY_DRAFT, JSON.stringify(obj)); }catch{}
}
function loadSaved(){
try{
const raw = localStorage.getItem(LS_KEY_SAVED);
if(!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.map(normalizeRecipe) : [];
}catch{ return []; }
}
function saveSaved(arr){
try{ localStorage.setItem(LS_KEY_SAVED, JSON.stringify(arr)); }catch{}
}
// ---- Utilities ----
function escapeHtml(s){
return String(s ?? "")
.replaceAll("&","&")
.replaceAll("<","<")
.replaceAll(">",">")
.replaceAll('"',""")
.replaceAll("'","'");
}
async function copyToClipboard(text){
try{
await navigator.clipboard.writeText(text);
}catch{
// fallback
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
let toastTimer = null;
function toast(msg){
el.toast.textContent = msg;
el.toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(()=>el.toast.classList.remove('show'), 1600);
}
// ---- Boot ----
initSelects();
bind();
applyDraftToUI();
setTab("core");
setRTab("preview");
renderRecommendations();
})();
</script>
</body>
</html>
index.html
index.html