feat(首页): 新增「孕期小知识」和「妈妈的树洞」两个模块 (v26093013)

孕期小知识(工具区下方):
- 按真实孕周 + 天数显示「今日小知识」大卡,右上角本周水果 emoji 缓慢浮动;
  底部 7 个圆点 = 本周第 1–7 天,今天高亮呼吸、以前可回看、以后锁着(明天见)。
- 四张马卡龙小卡:宝宝这周(带大小比喻)/ 妈妈身体 / 吃点什么 / 这周留心,进入视口依次浮上来。
- 内容 assets/data/preg-tips.json:孕 4–40 周,每周 4 句 + 每天 1 条,共 259 条,按需 fetch。
- 只在 /pregnancy 确认真实孕周后显示(pregEmpty === false 且非产后),不拿默认孕周配内容。

妈妈的树洞(今日脚印下方):
- 心事只存本机 localStorage(mmbox.treehole.v1,最多 200 条),不上传;按用户选定不做回应,
  只有纸团飞进树洞 + 树冠晃动 + 「已收好」动画;可展开「我的心事」回看、删除。
- 出现明显轻生字眼时,在「已收好」下加一行 12356 心理援助热线(兜底);卡底常驻一行求助提示。

动效在 prefers-reduced-motion 下关闭。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 13:23:00 +00:00
parent 9ea9f3c50f
commit 9a1ad1fb93
4 changed files with 364 additions and 4 deletions

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>兜爸心选 · 孕育陪伴</title>
<!-- V:26093012 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 -->
<!-- V:26093013 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 -->
<link rel="stylesheet" href="mama-box.css">
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<!-- 共享助手:授权/跳商城/位置上报(分享卡片同步,见 docs/h5-auth-standard.md 第四节) -->
@@ -304,6 +304,37 @@
</div>
</section>
<!-- 孕期小知识(2026-09-30)。用户原话:「按她填写的孕周,比如孕 20 周 5 天,给她列出几个小模块,
设计一定要漂亮、匹配母婴用户,最好是动态的;小知识匹配每周的每一天」。
内容:assets/data/preg-tips.json(孕 4–40 周,每周 宝宝/妈妈/饮食/注意 四句 + 每天一条小知识,共 7 条),
首次需要时才 fetch,不进首屏。渲染在 app.js renderTips()。
⚠️ 默认 .hide:只有拿到**真实孕周**(state.pregEmpty === false 且非产后)才显示,
内置默认孕周(DEFAULT_DUE 推出来的 27 周上下)是假的,不能拿它配知识。
⚠️ 一周 7 个圆点 = 这一周的第 1–7 天。今天及以前可以点开回看;以后的几天锁着,
写「明天见」—— 让每天回来看一条,而不是一口气翻完。 -->
<section class="preg-tips hide" id="pregTips">
<div class="pt-head">
<h2 class="pt-title">孕期小知识</h2>
<span class="pt-when" id="ptWhen"></span>
</div>
<div class="pt-today" id="ptToday">
<span class="pt-float" id="ptFloat" aria-hidden="true"></span>
<span class="pt-badge" id="ptBadge">今日小知识</span>
<div class="pt-body" id="ptBody">
<div class="pt-t" id="ptT"></div>
<div class="pt-d" id="ptD"></div>
</div>
<div class="pt-days" id="ptDays" role="tablist" aria-label="本周每天的小知识"></div>
</div>
<div class="pt-grid" id="ptGrid">
<div class="pt-card pc-baby"><span class="pt-ico" id="ptBabyIco" aria-hidden="true">👶</span><b>宝宝这周</b><p id="ptBaby"></p></div>
<div class="pt-card pc-mom"><span class="pt-ico" aria-hidden="true">🤰</span><b>妈妈身体</b><p id="ptMom"></p></div>
<div class="pt-card pc-eat"><span class="pt-ico" aria-hidden="true">🥗</span><b>吃点什么</b><p id="ptEat"></p></div>
<div class="pt-card pc-care"><span class="pt-ico" aria-hidden="true">📋</span><b>这周留心</b><p id="ptCare"></p></div>
</div>
<p class="pt-foot">仅供参考,身体有任何不适请以医生意见为准</p>
</section>
<!-- 模块二 · 今天的动线。
上一版这里是两个各自独立的模块:「AI今日小结」一张渐变卡,
下面「今日成长脚印」一张白卡装的时间轴。可 AI 那句话总结的
@@ -397,6 +428,48 @@
</button>
</section>
<!-- 树洞(2026-09-30)。用户原话:「宝妈们有任何情绪上的问题,都可以在这个树洞里扔进去」。
用户选定:**只存在她自己手机上**(localStorage 'mmbox.treehole.v1',不上传、后台也看不到),
扔进去后**只有「已收好」的动画,不做回应**。
⚠️ 唯一的例外是安全提示:内容里出现明显的轻生字眼时,收好之后在卡内显示一行援助热线
(12356 全国心理援助热线),这不是「回应心事」,是兜底,别删。
⚠️ 纸团飞进树洞的动画靠 Element.animate;不支持的老内核直接跳到「已收好」,不影响收存。 -->
<section class="tree-hole" id="treeHole">
<div class="th-head">
<h2 class="th-title">妈妈的树洞</h2>
<span class="th-lock"><svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true"><path d="M7 10V7.5a5 5 0 0 1 10 0V10" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><rect x="4.5" y="10" width="15" height="11" rx="3" fill="currentColor"/></svg>只存在你的手机里</span>
</div>
<div class="th-stage">
<p class="th-lead">烦心、委屈、害怕、说不出口的话,<br>都可以扔进来。树洞替你收着,<br>谁也看不到。</p>
<svg class="th-tree" id="thTree" viewBox="0 0 120 124" aria-hidden="true">
<g class="th-crown">
<circle cx="40" cy="42" r="26" fill="#BFE0B4"/>
<circle cx="78" cy="38" r="28" fill="#A9D59E"/>
<circle cx="60" cy="22" r="22" fill="#C9E7BF"/>
<circle cx="94" cy="58" r="17" fill="#B4DBA9"/>
<circle cx="26" cy="62" r="15" fill="#B4DBA9"/>
<circle cx="46" cy="28" r="3.2" fill="#F7B8A6"/><circle cx="84" cy="24" r="2.8" fill="#F9C9B8"/>
<circle cx="96" cy="50" r="2.6" fill="#F7B8A6"/><circle cx="30" cy="52" r="2.6" fill="#F9C9B8"/>
<circle cx="66" cy="44" r="2.4" fill="#F7B8A6"/>
</g>
<path d="M50 124c3-18 3-34 1-52h20c-2 18-2 34 1 52z" fill="#D4A27C"/>
<path d="M56 80c-6-6-14-8-20-7M66 80c5-6 12-8 18-7" stroke="#D4A27C" stroke-width="5" stroke-linecap="round" fill="none"/>
<ellipse id="thHole" class="th-hole" cx="61" cy="98" rx="6.5" ry="9" fill="#7A5238"/>
<ellipse cx="61" cy="99.5" rx="4" ry="6.2" fill="#5A3A26"/>
</svg>
</div>
<div class="th-form">
<textarea id="thText" maxlength="500" rows="3" placeholder="今天想说点什么……"></textarea>
<div class="th-row">
<button class="th-mine" id="thMine" type="button">我的心事 <b id="thCount">0</b> ›</button>
<button class="th-send" id="thSend" type="button">扔进树洞</button>
</div>
</div>
<div class="th-done" id="thDone" aria-live="polite"></div>
<ul class="th-list hide" id="thList"></ul>
<p class="th-help">如果持续情绪低落、睡不好,或有伤害自己的念头,请告诉家人或医生,也可拨打心理援助热线 <a href="tel:12356">12356</a>。</p>
</section>
<!-- 模块三 · 书架卡带。
上一版是「一张横排大卡 + 下面两张小卡」的老三段,和上面两个模块
一样都是往下堆。这里改成一条横向可滑的书架:三张等宽等高的**竖版**书

178
js/app.js
View File

@@ -3,7 +3,7 @@
(function () {
'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26093012';
var VER = '26093013';
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
@@ -1050,6 +1050,8 @@
function syncPregnancy() {
/* 孕期每日小打卡:产后整块收起;其它状态都显示(标签默认是中性提示,不依赖孕周) */
const _dc = $('#dailyCheck'); if (_dc) _dc.classList.toggle('hide', !!state.postpartum);
/* 孕期小知识:只认真实孕周(自己判显隐,见 renderTips) */
renderTips();
/* ---- 产后态:宝宝已出生 → 一律不显示孕周数字(2026-09-22) ----
⚠️ 必须放在 pregEmpty 之前:产后返回体的 week 也是 null,
落到空态分支会给**已经生产**的用户弹「完善孕期信息」引导。
@@ -3107,6 +3109,180 @@
}
}
/* ================= 孕期小知识(2026-09-30) =================
内容 assets/data/preg-tips.json:[{week, baby, mom, eat, care, days:[{t,d}×7]}],孕 4–40 周。
第几天 = state.day(0–6),和首页「孕 N 周 D 天」同一口径。
⚠️ 只在 state.pregEmpty === false(/pregnancy 已确认有孕周)且非产后时显示:
pregEmpty 初值是 undefined —— 档案没回来之前 state.week 是 DEFAULT_DUE 推出来的假孕周,
拿它配知识就是给人看假数据。所以这里用「=== false」而不是「!pregEmpty」。 */
let TIPS = null, tipsLoading = null, tipSel = -1;
function loadTips() {
if (!tipsLoading) {
tipsLoading = fetch('assets/data/preg-tips.json?v=' + VER)
.then(r => (r.ok ? r.json() : null))
.then(arr => {
const m = {};
(arr || []).forEach(x => { if (x && x.week) m[x.week] = x; });
TIPS = m;
})
.catch(() => { TIPS = {}; });
}
return tipsLoading;
}
function renderTips() {
const sec = $('#pregTips');
if (!sec) return;
if (state.pregEmpty !== false || state.postpartum) { sec.classList.add('hide'); return; }
if (!TIPS) { loadTips().then(renderTips); return; }
const w = Math.max(4, Math.min(40, state.week || 0));
const wk = TIPS[w];
if (!wk || !(wk.days || []).length) { sec.classList.add('hide'); return; }
const today = Math.max(0, Math.min(6, state.day || 0));
const size = babySize(w);
$('#ptWhen').textContent = '孕' + state.week + '周' + (state.day ? state.day + '天' : '');
$('#ptFloat').textContent = size.emoji;
$('#ptBabyIco').textContent = size.emoji;
$('#ptBaby').textContent = '像' + size.name + '那么大。' + (wk.baby || '');
$('#ptMom').textContent = wk.mom || '';
$('#ptEat').textContent = wk.eat || '';
$('#ptCare').textContent = wk.care || '';
/* 一周 7 个圆点:以前可回看,今天实心,以后锁着 */
const box = $('#ptDays');
box.textContent = '';
for (let i = 0; i < 7; i++) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'pt-day ' + (i < today ? 'is-past' : i === today ? 'is-today' : 'is-lock');
b.setAttribute('role', 'tab');
const n = document.createElement('i'); n.textContent = i + 1;
const t = document.createElement('span');
t.textContent = i === today ? '今天' : (i === today + 1 ? '明天' : '\u00a0'); // 圈里已是「第几天」,只给今天/明天配字
b.appendChild(n); b.appendChild(t);
b.addEventListener('click', () => {
if (i > today) { toast(i === today + 1 ? '明天再来看新的小知识~' : '到那天再来看哦~'); return; }
showTip(wk, i, today);
});
box.appendChild(b);
}
tipSel = -1;
showTip(wk, today, today);
sec.classList.remove('hide');
revealTipsGrid();
}
function showTip(wk, i, today) {
if (i === tipSel) return;
tipSel = i;
const d = wk.days[i] || wk.days[0];
$('#ptBadge').textContent = i === today ? '今日小知识' : '第 ' + (i + 1) + ' 天 · 回看';
$('#ptT').textContent = d.t || '';
$('#ptD').textContent = d.d || '';
$$('#ptDays .pt-day').forEach((b, k) => {
b.classList.toggle('is-sel', k === i);
b.setAttribute('aria-selected', k === i ? 'true' : 'false');
});
const body = $('#ptBody');
body.classList.remove('is-in'); void body.offsetWidth; body.classList.add('is-in'); // 重播进场动画
}
function revealTipsGrid() {
const g = $('#ptGrid');
if (!g || g.classList.contains('is-shown')) return;
if (!('IntersectionObserver' in window)) { g.classList.add('is-shown'); return; }
const io = new IntersectionObserver(es => {
if (es.some(e => e.isIntersecting)) { g.classList.add('is-shown'); io.disconnect(); }
}, { threshold: 0.2 });
io.observe(g);
}
/* ================= 妈妈的树洞(2026-09-30) =================
用户选定:心事**只存在本机**(localStorage,不上传),扔进去后只有「已收好」动画、不回应。
唯一例外:出现明显轻生字眼时,在「已收好」下面多一行援助热线 —— 兜底,不是回应。 */
(function treeHole() {
const KEY = 'mmbox.treehole.v1', MAX = 200;
const ta = $('#thText'), send = $('#thSend'), list = $('#thList'), done = $('#thDone');
if (!ta || !send) return;
const SOS = /想死|不想活|自杀|轻生|结束生命|活不下去|去死|割腕|跳楼/;
const load = () => { try { const a = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(a) ? a : []; } catch (e) { return []; } };
const save = a => { try { localStorage.setItem(KEY, JSON.stringify(a.slice(0, MAX))); return true; } catch (e) { return false; } };
const pad = n => (n < 10 ? '0' : '') + n;
const when = ts => { const d = new Date(ts); return (d.getMonth() + 1) + '月' + d.getDate() + '日 ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); };
function count() { $('#thCount').textContent = load().length; }
function renderList() {
const a = load();
list.textContent = '';
if (!a.length) {
const li = document.createElement('li'); li.className = 'th-empty'; li.textContent = '树洞还是空的';
list.appendChild(li); return;
}
a.forEach(e => {
const li = document.createElement('li');
const t = document.createElement('time'); t.textContent = when(e.t);
const p = document.createElement('p'); p.textContent = e.x;
const del = document.createElement('button'); del.className = 'th-del'; del.type = 'button';
del.setAttribute('aria-label', '删除这条'); del.textContent = '×';
del.addEventListener('click', () => {
if (!window.confirm('删掉这条心事?删了就找不回来了')) return;
save(load().filter(x => x.t !== e.t)); renderList(); count();
});
li.appendChild(t); li.appendChild(p); li.appendChild(del);
list.appendChild(li);
});
}
function showDone(sos) {
done.classList.toggle('is-sos', !!sos);
done.textContent = '已收好 🍃';
if (sos) {
const s = document.createElement('div'); s.className = 'th-sos';
s.textContent = '如果你现在很难受,请告诉身边的人,或拨打 12356 心理援助热线,有人愿意听你说。';
done.appendChild(s);
}
done.classList.add('is-on');
clearTimeout(showDone._t);
if (!sos) showDone._t = setTimeout(() => done.classList.remove('is-on'), 2600);
}
/* 纸团飞进树洞:从输入框中间出发,弧线落到树洞,边转边缩小 */
function fly(text, cb) {
const hole = $('#thHole'), tree = $('#thTree');
const note = document.createElement('div');
if (!hole || !note.animate) { cb(); return; }
note.className = 'th-note'; note.textContent = text;
document.body.appendChild(note);
const a = ta.getBoundingClientRect(), h = hole.getBoundingClientRect();
const nw = note.offsetWidth, nh = note.offsetHeight;
const x0 = a.left + a.width / 2 - nw / 2, y0 = a.top + a.height / 2 - nh / 2;
const x1 = h.left + h.width / 2 - nw / 2, y1 = h.top + h.height / 2 - nh / 2;
const xm = (x0 + x1) / 2, ym = Math.min(y0, y1) - 70;
const anim = note.animate([
{ transform: 'translate(' + x0 + 'px,' + y0 + 'px) scale(1) rotate(0)', opacity: 1, borderRadius: '10px' },
{ transform: 'translate(' + xm + 'px,' + ym + 'px) scale(.45) rotate(200deg)', opacity: 1, borderRadius: '50%', offset: .55 },
{ transform: 'translate(' + x1 + 'px,' + y1 + 'px) scale(.08) rotate(420deg)', opacity: .2, borderRadius: '50%' }
], { duration: 950, easing: 'cubic-bezier(.45,.05,.4,1)', fill: 'forwards' });
anim.onfinish = () => {
note.remove();
if (tree) { tree.classList.remove('is-eat'); void tree.getBoundingClientRect(); tree.classList.add('is-eat'); setTimeout(() => tree.classList.remove('is-eat'), 1000); }
cb();
};
}
const sync = () => { send.disabled = !ta.value.trim(); };
ta.addEventListener('input', sync);
send.addEventListener('click', () => {
const x = ta.value.trim();
if (!x) return;
const a = load(); a.unshift({ t: Date.now(), x: x });
if (!save(a)) { toast('手机存储空间不足,没能收好'); return; }
ta.value = ''; sync(); ta.blur();
fly(x, () => {
showDone(SOS.test(x));
count();
if (!list.classList.contains('hide')) renderList();
});
});
$('#thMine').addEventListener('click', () => {
const open = list.classList.toggle('hide') === false;
if (open) renderList();
});
sync(); count();
})();
/* 拉卡片数据(与其它远程数据一起打;失败静默,卡上保留现有值) */
function applyRemoteCardsFetch() {
const openid = hashParam('openid');

View File

@@ -429,7 +429,7 @@ a.skip:focus{left:16px;top:16px}
对齐的是内容,出血的是底色,这两件事要分开。
⚠️ 三块的圆角、底色、左右内边距改成在这里一处定义。以后要调,改这一条,
别再回到各自的规则里单独写,那正是上一稿乱掉的原因。 */
.daily-check,.tool-dock,.today-line,.shelf{
.daily-check,.tool-dock,.preg-tips,.today-line,.tree-hole,.shelf{
margin:0 -26px;padding:16px 26px 18px;border-radius:26px;
background-color:#fff;
/* ⚠️ 这两条是一对,缺一不可,而且必须一起看:
@@ -443,7 +443,7 @@ a.skip:focus{left:16px;top:16px}
/* 块间距。给后两块,第一块的上间距另有规则(来信卡收放要用)。
⚠️ 这是**块与块之间**的缝。某一块自己内部觉得挤,要调的是那块的 padding,
不是这里 —— 改这里会把三块的关系一起改掉。 */
.tool-dock,.today-line,.shelf{margin-top:16px}
.tool-dock,.preg-tips,.today-line,.tree-hole,.shelf{margin-top:16px}
/* ===== 后两块的暖色渐变底(本批新增)=====
用户原话:「在那个宝宝的今日脚印那块,稍微给背景它加一点渐变」
@@ -566,6 +566,116 @@ a.skip:focus{left:16px;top:16px}
.dc-tag.is-warn{color:var(--alert);background:#FDECE8}
.dc-row .chev{flex:0 0 auto;font-size:16px;margin-left:-4px}
/* ============ 孕期小知识(2026-09-30)============
外壳是公共白色通栏。里面三层:
① .pt-today 今日小知识大卡:暖粉渐变 + 右上角一颗慢慢浮动的「宝宝大小」emoji(本周水果);
底部 7 个圆点是本周第 1–7 天,今天实心、以前可回看、以后锁着。
② .pt-grid 四张马卡龙色小卡:宝宝 / 妈妈 / 饮食 / 注意,进入视口时依次浮上来。
③ .pt-foot 一行免责小字。
动效全部在 prefers-reduced-motion 下关掉。 */
.preg-tips.hide{display:none}
.pt-head{display:flex;align-items:baseline;gap:8px;margin-bottom:10px}
.pt-title{font-size:16.5px;font-weight:800;letter-spacing:-.2px}
.pt-when{margin-left:auto;font-size:12px;font-weight:700;color:var(--brand);
background:#FFF0EA;border-radius:999px;padding:3px 10px}
.pt-today{position:relative;overflow:hidden;border-radius:20px;padding:14px 16px 12px;
background:linear-gradient(135deg,#FFE9E1 0%,#FFF1E6 55%,#FFF7EE 100%)}
.pt-today::after{content:"";position:absolute;right:-30px;top:-40px;width:130px;height:130px;border-radius:50%;
background:radial-gradient(circle,rgba(255,255,255,.75),rgba(255,255,255,0) 70%);pointer-events:none}
.pt-float{position:absolute;right:14px;top:10px;font-size:34px;line-height:1;z-index:1;
animation:ptFloat 3.6s ease-in-out infinite;filter:drop-shadow(0 4px 6px rgba(200,110,80,.18))}
@keyframes ptFloat{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-6px) rotate(4deg)}}
.pt-badge{display:inline-block;font-size:11px;font-weight:700;color:#fff;border-radius:999px;padding:2px 9px;
background:linear-gradient(90deg,#F4593C,#FF8A66)}
.pt-body{min-height:88px;padding-right:44px}
.pt-body.is-in{animation:ptIn .42s var(--ease)}
@keyframes ptIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.pt-t{margin-top:8px;font-size:16px;font-weight:800;color:#5A2E22;letter-spacing:-.2px}
.pt-d{margin-top:5px;font-size:13px;line-height:1.65;color:#6B4B40}
.pt-days{display:flex;justify-content:space-between;margin-top:10px;padding-top:10px;border-top:1px dashed rgba(224,74,46,.18)}
.pt-day{flex:0 0 auto;display:flex;flex-direction:column;align-items:center;gap:3px;
background:none;border:0;padding:0;font:inherit;color:#B08A7C}
.pt-day i{display:block;width:26px;height:26px;border-radius:50%;line-height:26px;text-align:center;
font-style:normal;font-size:11.5px;font-weight:700;background:rgba(255,255,255,.75);color:#C07A64;
transition:transform .2s var(--ease),background .2s}
.pt-day span{font-size:10px}
.pt-day.is-past i{background:#fff;color:#E0725A}
.pt-day.is-today i{background:linear-gradient(135deg,#F4593C,#FF8A66);color:#fff;
box-shadow:0 0 0 3px rgba(244,89,60,.18);animation:ptPulse 2.4s ease-in-out infinite}
.pt-day.is-today span{color:var(--brand);font-weight:700}
@keyframes ptPulse{0%,100%{box-shadow:0 0 0 3px rgba(244,89,60,.18)}50%{box-shadow:0 0 0 6px rgba(244,89,60,.06)}}
.pt-day.is-sel i{transform:scale(1.14)}
.pt-day.is-sel:not(.is-today) i{background:#FFD9CC;color:#C24A30}
.pt-day.is-lock i{background:rgba(255,255,255,.45);color:#D9BDB2}
.pt-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}
.pt-card{position:relative;border-radius:16px;padding:11px 12px 12px;min-width:0}
.pt-card b{display:block;font-size:13px;font-weight:800;margin-top:4px}
.pt-card p{margin:4px 0 0;font-size:12px;line-height:1.55;color:var(--ink-2)}
.pt-ico{display:inline-grid;place-items:center;width:30px;height:30px;border-radius:10px;background:rgba(255,255,255,.8);font-size:17px}
.pc-baby{background:linear-gradient(160deg,#FFF1E8,#FFE6DA)} .pc-baby b{color:#C4583C}
.pc-mom{background:linear-gradient(160deg,#FCEFF5,#F8E1EC)} .pc-mom b{color:#B3547E}
.pc-eat{background:linear-gradient(160deg,#EFF7EA,#E3F0DB)} .pc-eat b{color:#4F7F3E}
.pc-care{background:linear-gradient(160deg,#EEF3FB,#E1EAF7)} .pc-care b{color:#4A6FA6}
/* 进入视口依次浮上来(JS 给 .pt-grid 加 .is-shown;不支持 IntersectionObserver 时直接加) */
.pt-grid .pt-card{opacity:0;transform:translateY(12px);transition:opacity .5s ease,transform .6s var(--ease)}
.pt-grid.is-shown .pt-card{opacity:1;transform:none}
.pt-grid.is-shown .pt-card:nth-child(2){transition-delay:.08s}
.pt-grid.is-shown .pt-card:nth-child(3){transition-delay:.16s}
.pt-grid.is-shown .pt-card:nth-child(4){transition-delay:.24s}
.pt-foot{margin-top:9px;font-size:11px;color:var(--muted);text-align:center}
/* ============ 妈妈的树洞(2026-09-30)============
浅薄荷→奶白的底,和上下暖色块拉开,读起来「安静」。右侧一棵 SVG 小树,
树干上的洞(#thHole)是纸团飞进去的落点。扔进去后:纸团飞入 → 洞口亮一下、树冠晃一下
→ 卡内出现「已收好」。不做任何回应文字(用户选定)。 */
.tree-hole{background-image:linear-gradient(160deg,#F1F8EE 0%,#FAFCF6 55%,#FFFDF9 100%)}
.th-head{display:flex;align-items:center;gap:8px}
.th-title{font-size:16.5px;font-weight:800;letter-spacing:-.2px}
.th-lock{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:11.5px;font-weight:600;
color:#5E8A55;background:#E4F1DF;border-radius:999px;padding:3px 9px}
.th-stage{display:flex;align-items:flex-end;gap:6px;margin-top:6px}
.th-lead{flex:1;min-width:0;font-size:13px;line-height:1.7;color:#5C6B58;padding-bottom:10px}
.th-tree{flex:0 0 auto;width:104px;height:108px;overflow:visible}
.th-crown{transform-origin:60px 70px;animation:thSway 5s ease-in-out infinite}
@keyframes thSway{0%,100%{transform:rotate(-1.2deg)}50%{transform:rotate(1.2deg)}}
.th-tree.is-eat .th-crown{animation:thShake .7s ease-in-out}
@keyframes thShake{0%,100%{transform:rotate(0)}25%{transform:rotate(-4deg)}60%{transform:rotate(3deg)}}
.th-tree.is-eat .th-hole{animation:thGlow .9s ease-out}
@keyframes thGlow{0%{fill:#7A5238}35%{fill:#F4C38F}100%{fill:#7A5238}}
.th-form{margin-top:4px;background:#fff;border:1px solid #E3EEDD;border-radius:16px;padding:10px 12px}
.th-form textarea{display:block;width:100%;border:0;outline:0;resize:none;background:transparent;
font:inherit;font-size:14px;line-height:1.6;color:var(--ink)}
.th-form textarea::placeholder{color:#A9B5A4}
.th-row{display:flex;align-items:center;gap:8px;margin-top:6px}
.th-mine{border:0;background:none;padding:0;font:inherit;font-size:12px;font-weight:600;color:#6F8A68}
.th-mine b{font-weight:800}
.th-send{margin-left:auto;border:0;border-radius:999px;padding:8px 18px;font:inherit;font-size:13.5px;font-weight:700;
color:#fff;background:linear-gradient(90deg,#7FB573,#98C98A);box-shadow:0 6px 14px rgba(110,160,95,.25)}
.th-send:disabled{opacity:.45;box-shadow:none}
.th-send:active{transform:scale(.96)}
.th-done{height:0;overflow:hidden;text-align:center;font-size:13px;font-weight:700;color:#5E8A55;
transition:height .3s var(--ease),margin .3s var(--ease)}
.th-done.is-on{height:22px;margin-top:10px;animation:ptIn .4s var(--ease)}
.th-done .th-sos{font-weight:500;color:var(--ink-2)}
.th-done.is-sos{height:auto}
.th-note{position:fixed;z-index:60;left:0;top:0;pointer-events:none;border-radius:10px;
background:#FFFDF6;box-shadow:0 6px 18px rgba(90,110,80,.25);padding:6px 9px;font-size:12px;color:var(--ink-2);
max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.th-list{list-style:none;margin:10px 0 0;padding:0}
.th-list.hide{display:none}
.th-list li{position:relative;background:#fff;border-radius:14px;padding:9px 34px 9px 12px;margin-top:8px;
border:1px solid #EAF2E6}
.th-list time{display:block;font-size:11px;color:#8FA088}
.th-list p{margin:3px 0 0;font-size:13px;line-height:1.6;color:var(--ink-2);white-space:pre-wrap;word-break:break-all}
.th-list .th-del{position:absolute;right:8px;top:8px;border:0;background:none;padding:2px 4px;font-size:15px;color:#B9C4B4}
.th-list .th-empty{text-align:center;color:var(--muted);font-size:12px;background:none;border:0}
.th-help{margin-top:10px;font-size:11px;line-height:1.6;color:var(--muted)}
.th-help a{color:#5E8A55;font-weight:700}
@media (prefers-reduced-motion:reduce){
.pt-float,.pt-day.is-today i,.th-crown{animation:none}
.pt-grid .pt-card{opacity:1;transform:none;transition:none}
}
/* ============ 模块一 · 工具 ============ */
.dock-head{display:flex;align-items:center;gap:8px;margin-bottom:7px}
/* 说明行仍是 inline-flex:前面那枚小图标要和文字同行居中,