
Fitness Island
- 20 installs
- 2 repo stars
- Updated June 10, 2026
- hiyeshu/fitness-island
Plan a low-friction beginner fitness habit from body context, goals, and equipment, then generate an Animal Island style mobile check-in page with a 7-day route.
About
Runs a Scan-Tidy-Build pipeline that gathers real constraints, cuts high-friction actions into a minimal route, and generates an Animal Island style static check-in page with daily tasks and weekly review. A user runs it to build a deployable beginner fitness habit tracker.
- Writes fitness-plan.md and plan.json, keeping locked page assets byte-for-byte
- Shared ~/.fitness-island/MEMORY.md carries durable training defaults and safety stop rules
Fitness Island by the numbers
- 20 all-time installs (skills.sh)
- Ranked #2,015 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hiyeshu/fitness-island --skill fitness-islandAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 20 |
|---|---|
| repo stars | ★ 2 |
| Last updated | June 10, 2026 |
| Repository | hiyeshu/fitness-island ↗ |
What it does
Plan a low-friction beginner fitness habit from body context, goals, and equipment, then generate an Animal Island style mobile check-in page with a 7-day route.
Files
<!-- [INPUT]: 依赖用户身体情况、训练目标、时间窗口、器械照片或文字描述、~/.fitness-island/MEMORY.md 共享记忆、assets/ canonical 页面资产和 scripts/ 工具 [OUTPUT]: 对外提供 fitness-plan.md、plan.json、锁定 assets 同构的动森风格静态打卡页、可复制 agent 训练数据、共享记忆更新和交付验证流程 [POS]: fitness-island 独立技能入口,按 Scan -> Tidy -> Build 流程把现实约束变成可部署的运动习惯项目 [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md -->
动森训练岛 - 运动习惯养成 Skill
Three-phase pipeline: Scan → Tidy → Build.
The output is a reference habit system, not a script the body must obey. Fatigue, pain, sleep, work, and available time can override the route.
Truth sources
Keep the map small:
~/.fitness-island/MEMORY.md # durable facts for next time
fitness-plan.md # human-readable route truth
plan.json # page data projectionfitness-plan.md owns intent. plan.json owns rendering. MEMORY.md owns only stable carryover facts.
Shared memory
Use exactly one memory file: ~/.fitness-island/MEMORY.md.
Before planning or building, read ~/.fitness-island/MEMORY.md if it exists. If it does not exist, create the minimal file directly and continue.
Use it only for durable training context:
- default training place
- available equipment
- time window
- minimum promise
- reset route
- pain or old-injury constraints
- reward cue
- previous weekly review conclusions
- generated project paths
Do not store raw body photos, medical records, full chat logs, precise private address, or other sensitive material.
After each completed route, page build, or weekly review, update ~/.fitness-island/MEMORY.md with only durable facts:
Use this shape when creating the file:
# Fitness Island Memory
## 稳定默认值
- 训练地点:
- 可用器械:
- 时间窗口:
- 最低承诺:
- 默认路线:
- 重启路线:
- 奖励提示:
## 身体边界
- 疼痛/旧伤:
- 需要避免:
## 周复盘结论
- 最稳动作:
- 最弱动作:
- 下周先减少:
- 下周再增加:
## 项目索引
| 日期 | 项目路径 | 输出 | 备注 |
|------|----------|------|------|
## Agent 交接
- 上次导出:
- 待确认:Phase 1: Scan the real constraints
Read references/minimum-friction.md before planning when the task involves making exercise sustainable, restarting after skipped days, reducing resistance, or designing the minimum promise.
Core sequence:
1. Extract hard constraints — body context, goal, real time window, place, equipment, pain risk, old injuries 2. Classify training surface — home / gym / treadmill / machines / walking only / no equipment 3. Find friction — what the user hates, forgets, delays, overthinks, or cannot start in two minutes 4. Check safety boundary — sharp pain, numbness, dizziness, chest pain, faintness, old injury flare-up 5. Write constraint summary — conclusion first, then assumptions and safe defaults
Key principles:
- Smart skip: if the user already gave enough information, do not re-ask it.
- Ask at most two rounds, then default conservatively.
- Ask only questions that change the route.
- Never ask for daily feelings by default.
- All user-facing questions follow the 4-beat format:
Re-ground → Simplify → Recommend → Options.
Use the 4-beat format when a decision is needed:
Re-ground: 动森训练岛,Step 1: 归位
Simplify: 我需要先知道你在哪里练、每天能拿出多久、有没有伤痛。
Recommend: 建议先用最短 20 分钟路线,先建立出现习惯,再加量。
Options:
A) 家里练,20 分钟
B) 健身房,30 分钟
C) 只有散步,10-15 分钟
D) 我补充器械照片/身体限制Mandatory questions only when missing:
- body risk: pain, old injury, numbness, dizziness, chest pain
- time window: realistic daily duration
- place/equipment: where training happens and what is available
- goal conflict: for example zero training base plus extreme-speed fat loss
If the user asks for personalized loading while pain, old injury, numbness, dizziness, chest pain, or faintness is unknown, ask the safety question first. For low-intensity habit pages, default to "no red-flag symptoms reported" and write the stop rules.
Phase 2: Tidy into a route
Treat planning like tidying. Delete before adding. Use references/minimum-friction.md to choose the minimum route, default route, reset route, if-then plan, and weekly review rule.
Core sequence:
1. Cut high-friction actions first — technical lifts, complex setup, crowded machines, routes longer than the real window 2. Pick the first action — the action that can start in two minutes 3. Set minimum promise — the smallest action that keeps the chain alive 4. Set default route — the normal order when energy is fine 5. Set reset route — what to do after a missed day, bad sleep, pain warning, or schedule collapse 6. Write weekly review rule — reduce the weakest part before adding volume 7. Write `fitness-plan.md` — route truth before any page data
Key principles:
- Keep actions that are obvious, available, low risk, and repeatable.
- Prefer walking, low-tech machines, low weight, and short routes for beginners.
- Do not reward intensity before consistency.
- The route only needs three stable pieces: minimum promise, default route,
reset route.
fitness-plan.mdmust include: constraints, what was cut, minimum promise,
default route, reset route, weekly review rule, and safety stop rules.
- Before building the page, show the route summary and wait for confirmation
when the user has not clearly authorized generation.
Phase 3: Build the island page
Default Build mode is data-only after seeding.
1. Run scripts/init_project.sh "$PROJECT_DIR" before writing route data. 2. Ensure ~/.fitness-island/MEMORY.md exists 3. Write or update fitness-plan.md 4. Fill plan.json from fitness-plan.md. Use references/plan-json.md: 30 days is the default route, not a prison. 5. Do not hand-write index.html. Do not replace the canonical shell with a simplified framework. 6. Keep locked assets byte-for-byte copied from assets/ 7. Keep the page behavior complete: 动森训练岛 hero, favicon, 7-day route, daily task cards, one settlement button, reward stamp modal, weekly review table, copyable agent training data, and bottom navigation 8. Generate local rounded Chinese font subsets only when production packaging needs self-hosted rounded fonts; never load cloud fonts at runtime 9. Run scripts/verify_assets.py "$PROJECT_DIR" and verify in browser before delivery
Copied reference targets:
index.html # seeded from assets/index.html
styles.css # seeded from assets/styles.css
app.js # seeded from assets/app.js
design.md # seeded from assets/design.md
assets/favicon.svg
assets/animal-island/Locked asset contract:
- These files are not route data:
index.html,styles.css,app.js,
design.md, assets/favicon.svg, assets/animal-island/*.
- In normal page generation they must stay byte-for-byte identical to the
matching file under this skill's assets/.
- The user-specific plan lives only in
fitness-plan.mdandplan.json. - If
scripts/verify_assets.py "$PROJECT_DIR"fails, re-run
scripts/init_project.sh "$PROJECT_DIR" and rewrite data files. Do not patch the generated HTML shell.
- UI component changes are a separate mode: first update this skill's
assets/design.md, then assets/index.html, assets/styles.css, and assets/app.js; update CLAUDE.md/README.md; then reseed projects from assets/.
Generated for the user route:
fitness-plan.md # human-readable truth source
plan.json # seeded from assets/plan.example.json, then rewritten as page dataOptional production asset:
assets/fonts/ # generated WOFF2 subsets, only when self-hosted fonts are requiredfitness-plan.md owns training intent. plan.json owns training data for the page. design.md owns UI decisions. HTML is locked shell, CSS is visual state, JS is projection.
UI system
Use only these component meanings:
Hero— title, day meta, game prompt, animal art, progress meterRouteCard/RouteNode— 7-day route; node text is number onlyTaskCard— checkbox, action name, parameter pill, noteCompleteStrip— one yellow settlement entryCompleteRewardModal— reward stamp,盖章啦, collect buttonReviewTable— visible weekly review table, not Markdown<pre>ArchiveCollapse— 30-day map entryFloatingNav— previous, settle/complete, next
Settlement labels:
0/total:完成今天1..total-1/total:今天到这total/total:完成今天- after settlement:
今天已完成
Fonts
Default source: Resource Han Rounded CN 0.990.
Do not ship full TTF/OTF/WOFF2 font families inside the skill. Generated projects must run with system fallback fonts. Ship WOFF2 subsets only when self-hosted rounded Chinese fonts are required and the files exist:
assets/fonts/fitness-rounded-cn-medium.woff2
assets/fonts/fitness-rounded-cn-heavy.woff2
assets/fonts/OFL-License.txtCommands:
bash scripts/init_project.sh "$PROJECT_DIR"
python3 scripts/collect_text.py --root "$PROJECT_DIR" --out /tmp/fitness-text.txt
python3 scripts/validate_plan.py "$PROJECT_DIR/plan.json"
python3 scripts/verify_assets.py "$PROJECT_DIR"
python3 scripts/build_font_subset.py --text /tmp/fitness-text.txt --medium "$MEDIUM_TTF" --heavy "$HEAVY_TTF" --out "$PROJECT_DIR/assets/fonts"
python3 scripts/check_glyphs.py --text /tmp/fitness-text.txt --font "$PROJECT_DIR/assets/fonts/fitness-rounded-cn-medium.woff2"
node --check "$PROJECT_DIR/app.js"Deploy (optional)
Static output can be deployed by Git push to Vercel, or served locally:
python3 -m http.server 4173Dependencies
| Tool | Purpose | Install |
|---|---|---|
| Python 3 | init, text collection, font scripts | system Python or brew install python |
| fontTools + brotli | WOFF2 subset generation and glyph checks | python3 -m pip install fonttools brotli |
| Resource Han Rounded CN | rounded Chinese source font | download/cache locally |
| Browser | mobile visual QA | Chrome / Codex browser |
Resources
references/minimum-friction.md— sustainability, minimum resistance, habit
cue, reset route, and weekly review reference
references/plan-json.md— flexibleplan.jsonhard contract and soft
recommendations
assets/index.html— canonical Fitness Island page shellassets/styles.css— canonical Animal Island visual layerassets/app.js— canonical static app behaviorassets/design.md— UI contract referenceassets/favicon.svg— rounded favicon for generated pagesassets/plan.example.json— runnable route example and seedassets/animal-island/— required native visual assets referenced by CSSscripts/init_project.sh— seed project files from assetsscripts/validate_plan.py— lightweightplan.jsonvalidatorscripts/verify_assets.py— byte-for-byte lock for canonical page assetsscripts/collect_text.py— collect project text for font subsettingscripts/build_font_subset.py— build WOFF2 subsetsscripts/check_glyphs.py— block delivery on missing glyphs
Verification
- no missing Chinese glyphs
plan.jsonpassesscripts/validate_plan.pyindex.html,styles.css,app.js,design.md, favicon, and
animal-island assets pass scripts/verify_assets.py
- no clipped or overlapping text
- mobile first screen shows the main action
- fixed footer sea does not cover the nav
- partial completion records
今天到这without filling unfinished actions - full completion opens the
盖章啦reward modal - weekly review table is visible on review days
- exported agent training data can drive the next route adjustment
Safety boundary
This skill provides practical habit and training references, not medical diagnosis. Sharp joint pain, numbness, tingling, dizziness, chest pain, faintness, pain that changes movement quality, or old injury flare-up stops progression. Reduce load first; seek professional care when symptoms are not ordinary effort.
.DS_Store
__pycache__/
*.py[cod]
.pytest_cache/
node_modules/
tmp/
animal-island/
L2 | 父级: ../CLAUDE.md
成员清单 animal-icon.png: Hero 伙伴贴图,被 styles.css 的 topbar 与移动端 hero-animal 使用。 cursor-icon-small.png: 小光标贴图,被 body、button 和 summary 的 cursor 样式使用。 divider-line-brown.svg: 棕色分隔线,被 section-head 和 archive-divider 使用。 footer-sea.svg: 底部前景海浪,被 animal-footer-sea 固定层使用。 home-bg.webp: 叶子背景纹理,被 body 背景滚动层使用。
架构决策 本目录只保存 styles.css 当前实际引用的 animal-island-ui 原生视觉资产。未引用素材不进入 skill,避免资产目录变成素材仓库。
变更日志 2026-05-25: 独立仓库化,父级链接改为相对路径。 2026-05-25: 从项目 assets/animal-island 收敛复制 5 个实际引用素材。
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
/*
[INPUT]: 依赖 index.html 的路线 DOM、plan.json 的 30 天训练路线、浏览器剪贴板能力、fetch/XMLHttpRequest、可选 localStorage 和 topMeta 打字机容器
[OUTPUT]: 对外提供由 plan.json 的 title/summary/type/phase/minutes 驱动的「动森训练岛」hero 文案、Animal Loading 启动状态、打字机日程行、游戏提示语、今日进度条、无文字状态标签的 7 日路线渲染、每日动作卡、黄色主按钮结算、奖励式每日完成弹窗、周复盘 Table 渲染、导出记录给 Agent、周复盘 Markdown 生成、plan.json 兜底加载和本地临时缓存
[POS]: fitness-island/assets 的静态行为目标,plan.json 是页面数据投影,本文件只做渲染与导出交接
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
*/
const STORAGE_KEY = "fitness-island-v1";
const DAY_MS = 24 * 60 * 60 * 1000;
const $ = (id) => document.querySelector(`#${id}`);
const nodes = {
islandLoading: $("islandLoading"),
islandLoadingText: $("islandLoadingText"),
topMeta: $("topMeta"),
topTitle: $("topTitle"),
topSummary: $("topSummary"),
heroProgressFill: $("heroProgressFill"),
heroProgressText: $("heroProgressText"),
weekPlant: $("weekPlant"),
handoffTrigger: $("handoffTrigger"),
weekLabel: $("weekLabel"),
weekSignal: $("weekSignal"),
weekRoute: $("weekRoute"),
phaseLabel: $("phaseLabel"),
sequenceTitle: $("sequenceTitle"),
completionText: $("completionText"),
exerciseList: $("exerciseList"),
completeDay: $("completeDay"),
reviewPanel: $("reviewPanel"),
reviewAppear: $("reviewAppear"),
reviewFull: $("reviewFull"),
reviewMinimum: $("reviewMinimum"),
reviewTable: $("reviewTable"),
copyReview: $("copyReview"),
calendarGrid: $("calendarGrid"),
toast: $("toast"),
completeDialog: $("completeDialog"),
closeComplete: $("closeComplete"),
handoffDialog: $("handoffDialog"),
painInput: $("painInput"),
resistanceInput: $("resistanceInput"),
cancelHandoff: $("cancelHandoff"),
copyHandoff: $("copyHandoff"),
openResetFromHandoff: $("openResetFromHandoff"),
resetDialog: $("resetDialog"),
cancelReset: $("cancelReset"),
confirmReset: $("confirmReset"),
navPrev: $("navPrev"),
navComplete: $("navComplete"),
navNext: $("navNext")
};
let route = { weeks: [] };
let days = [];
let state = createState();
let toastTimer;
let heroTypeKey = "";
let heroTypeTimer;
function todayKey() {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function addDays(dateKey, offset) {
const [year, month, day] = dateKey.split("-").map(Number);
const date = new Date(year, month - 1, day);
date.setDate(date.getDate() + offset);
const nextYear = date.getFullYear();
const nextMonth = String(date.getMonth() + 1).padStart(2, "0");
const nextDay = String(date.getDate()).padStart(2, "0");
return `${nextYear}-${nextMonth}-${nextDay}`;
}
function createState() {
return {
startDate: todayKey(),
lastSeen: todayKey(),
selectedDay: 1,
checks: {},
days: {},
settled: {}
};
}
function readState() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
return { ...createState(), ...saved };
} catch {
return createState();
}
}
function writeState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// localStorage 是缓存,不是系统真相源;不可用时保持当前会话可用。
}
}
function loadJson(url) {
if (typeof fetch === "function") {
return fetch(url).then((response) => {
if (!response.ok) throw new Error("plan.json load failed");
return response.json();
}).catch(() => loadJsonWithRequest(url));
}
return loadJsonWithRequest(url);
}
function loadJsonWithRequest(url) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("GET", url);
request.responseType = "json";
request.addEventListener("load", () => {
if (request.status < 200 || request.status >= 300) {
reject(new Error(`HTTP ${request.status}`));
return;
}
resolve(request.response || JSON.parse(request.responseText));
});
request.addEventListener("error", () => reject(new Error("plan load failed")));
request.send();
});
}
function loadDays(plan) {
const weekDays = plan.weeks.flatMap((week, weekIndex) =>
week.days.map((day, dayIndex) => normalizeDay(day, week, weekIndex, dayIndex))
);
const bufferDays = (plan.buffer?.days || []).map((day, dayIndex) =>
normalizeDay(day, plan.buffer, plan.weeks.length, dayIndex)
);
return [...weekDays, ...bufferDays]
.slice(0, 30)
.map((day, index) => ({ ...day, planDay: index + 1 }));
}
function normalizeDay(day, group, weekIndex, dayIndex) {
return {
...day,
weekIndex,
dayIndex,
weekTheme: group.theme,
weekSignal: group.signal,
weekSentence: group.sentence,
plant: group.plant
};
}
function currentPlanDay() {
const [startYear, startMonth, startDay] = state.startDate.split("-").map(Number);
const [nowYear, nowMonth, nowDay] = todayKey().split("-").map(Number);
const start = new Date(startYear, startMonth - 1, startDay);
const now = new Date(nowYear, nowMonth - 1, nowDay);
const diff = Math.floor((now - start) / DAY_MS) + 1;
return Math.min(days.length, Math.max(1, diff));
}
function planFor(day) {
return days[Math.min(days.length, Math.max(1, day)) - 1];
}
function weekFor(day) {
const plan = planFor(day);
return {
theme: plan.weekTheme,
signal: plan.weekSignal,
sentence: plan.weekSentence,
plant: plan.plant
};
}
function weekRangeFor(day) {
const weekIndex = planFor(day).weekIndex;
const sameWeek = days.filter((item) => item.weekIndex === weekIndex);
const startDay = sameWeek[0].planDay;
const endDay = sameWeek.at(-1).planDay;
return { startDay, endDay };
}
function dayChecks(day) {
return state.checks[day] || [];
}
function setDayChecks(day, checks) {
state.checks[day] = checks;
}
function isDayDone(day) {
return Boolean(state.days[day]);
}
function isDaySettled(day) {
return Boolean(state.settled?.[day]);
}
function dayAppeared(day) {
return isDayDone(day) || isDaySettled(day) || dayChecks(day).some(Boolean);
}
function checkedCount(day) {
return dayChecks(day).filter(Boolean).length;
}
function dayStatus(day) {
if (isDayDone(day)) return "完整";
if (dayAppeared(day)) return "出现";
return "待补";
}
function routeStatusLabel(day, current) {
if (isDayDone(day)) return "完成";
if (day === current) return dayAppeared(day) ? "进行" : "今天";
if (day < current && !dayAppeared(day)) return "未完成";
if (dayAppeared(day)) return "出现";
return "待做";
}
function peakAppearedStreak(weekDays) {
let peak = 0;
let count = 0;
weekDays.forEach((day) => {
count = dayAppeared(day) ? count + 1 : 0;
peak = Math.max(peak, count);
});
return peak;
}
function exerciseReviewStats(weekDays) {
const stats = new Map();
weekDays.forEach((day) => {
const checks = dayChecks(day);
planFor(day).exercises.forEach(([name], index) => {
const stat = stats.get(name) || { name, done: 0, total: 0 };
stat.total += 1;
if (checks[index]) stat.done += 1;
stats.set(name, stat);
});
});
const list = [...stats.values()];
const byRatio = (item) => item.done / item.total;
const active = list.filter((item) => item.done > 0);
const stable = active.sort((a, b) => byRatio(b) - byRatio(a) || b.done - a.done)[0];
const weakest = list.sort((a, b) => byRatio(a) - byRatio(b) || a.done - b.done)[0];
const format = (item) => item ? `${item.name} (${item.done}/${item.total})` : "待记录";
return {
stable: format(stable),
weakest: format(weakest)
};
}
function dayTrailItem(day) {
if (isDayDone(day)) return { label: `D${day}`, status: "done", text: "✓" };
if (dayAppeared(day)) return { label: `D${day}`, status: "appeared", text: "·" };
return { label: `D${day}`, status: "missed", text: "x" };
}
function weeklyReview(day = state.selectedDay, input = {}) {
const { startDay, endDay } = weekRangeFor(day);
const weekDays = Array.from({ length: endDay - startDay + 1 }, (_, index) => startDay + index);
const appeared = weekDays.filter(dayAppeared).length;
const full = weekDays.filter(isDayDone).length;
const minimum = weekDays.filter((item) => dayAppeared(item) && !isDayDone(item)).length;
const weekNumber = planFor(day).weekIndex + 1;
const startDate = addDays(state.startDate, startDay - 1);
const endDate = addDays(state.startDate, endDay - 1);
const stats = exerciseReviewStats(weekDays);
const trailItems = weekDays.map(dayTrailItem);
const trail = weekDays
.map((item) => `Day ${item}${isDayDone(item) ? "✓" : dayAppeared(item) ? "·" : "x"}`)
.join(" ");
const lines = [
`## Last Week (Week ${weekNumber}, ${startDate} ~ ${endDate})`,
`完成: ${full}/${weekDays.length} (${trail})`,
`最稳: ${stats.stable}`,
`最弱: ${stats.weakest}`,
`连续到场峰值: ${peakAppearedStreak(weekDays)} 天`
];
const pain = input.pain?.trim();
const resistance = input.resistance?.trim();
if (pain) lines.push(`疼痛: ${pain}`);
if (resistance) lines.push(`阻力: ${resistance}`);
lines.push("", "→ 按 Next Week Rule 生成下周");
return {
appeared,
full,
minimum,
trailItems,
rows: [
{ key: "period", item: "周期", value: `第 ${weekNumber} 周`, note: `${startDate} ~ ${endDate}` },
{ key: "completion", item: "完成", value: `${full}/${weekDays.length}`, note: trailItems },
{ key: "stable", item: "最稳", value: stats.stable, note: "保持这个入口" },
{ key: "weakest", item: "最弱", value: stats.weakest, note: "下周别加码" },
{ key: "streak", item: "连续", value: `${peakAppearedStreak(weekDays)} 天`, note: "到场峰值" }
],
text: lines.join("\n")
};
}
function appendTextCell(row, text, className) {
const cell = document.createElement("td");
if (className) cell.className = className;
cell.textContent = text;
row.appendChild(cell);
return cell;
}
function appendTrailCell(row, items) {
const cell = document.createElement("td");
cell.className = "review-trail-cell";
const wrap = document.createElement("div");
wrap.className = "review-trail";
items.forEach((item) => {
const chip = document.createElement("span");
chip.className = `review-day-chip ${item.status}`;
chip.textContent = `${item.label}${item.text}`;
wrap.appendChild(chip);
});
cell.appendChild(wrap);
row.appendChild(cell);
}
function renderReviewTable(review) {
nodes.reviewTable.replaceChildren();
const table = document.createElement("table");
table.className = "review-table";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
["项目", "结果", "记录"].forEach((title) => appendTextCell(headRow, title));
head.appendChild(headRow);
const body = document.createElement("tbody");
review.rows.forEach((item, index) => {
const row = document.createElement("tr");
if (index % 2 === 1) row.className = "striped";
appendTextCell(row, item.item, "review-item-cell");
appendTextCell(row, item.value, "review-value-cell");
if (Array.isArray(item.note)) appendTrailCell(row, item.note);
else appendTextCell(row, item.note, "review-note-cell");
body.appendChild(row);
});
table.append(head, body);
nodes.reviewTable.appendChild(table);
}
function typewriterLine(node, text, key) {
window.clearTimeout(heroTypeTimer);
if (heroTypeKey === key || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
node.textContent = text;
heroTypeKey = key;
return;
}
heroTypeKey = key;
node.textContent = "";
let index = 0;
const tick = () => {
node.textContent = text.slice(0, index);
index += 1;
if (index <= text.length) heroTypeTimer = window.setTimeout(tick, 42);
};
tick();
}
function renderTop(day) {
const plan = planFor(day);
const weekName = plan.weekIndex >= route.weeks.length ? "缓冲" : `第 ${plan.weekIndex + 1} 周`;
const week = weekFor(day);
const done = isDayDone(day);
const appeared = dayAppeared(day);
const checked = checkedCount(day);
const total = plan.exercises.length;
const statusLine = done ? "锻炼啦!已盖章" : appeared ? (plan.summary || "锻炼啦!继续走") : (plan.summary || week.sentence || "锻炼啦!待盖章");
const metaParts = [`${weekName} · 第 ${day} 天`];
if (plan.minutes) metaParts.push(`${plan.minutes} 分钟`);
nodes.topTitle.textContent = "动森训练岛";
typewriterLine(nodes.topMeta, metaParts.join(" · "), `${day}:${weekName}:${plan.minutes || ""}`);
nodes.topSummary.textContent = statusLine;
nodes.heroProgressFill.style.width = `${Math.round((checked / total) * 100)}%`;
nodes.heroProgressText.textContent = `${checked}/${total}`;
nodes.weekLabel.textContent = `${weekName} · 第 ${day} 天`;
nodes.weekSignal.textContent = done ? "今天已完成" : appeared ? "今天进行中" : (week.signal || "今天未完成");
nodes.weekPlant.dataset.plant = week.plant || "sprout";
nodes.phaseLabel.textContent = [plan.type, plan.phase].filter(Boolean).join(" · ");
nodes.sequenceTitle.textContent = plan.title || "今日顺序";
}
function renderWeekRoute(day) {
const current = currentPlanDay();
const { startDay, endDay } = weekRangeFor(day);
nodes.weekRoute.innerHTML = "";
for (let item = startDay; item <= endDay; item += 1) {
const button = document.createElement("button");
button.type = "button";
button.className = "route-node";
const label = routeStatusLabel(item, current);
button.setAttribute("aria-label", `第 ${item} 天,${label}`);
if (item === day) {
button.classList.add("selected");
button.setAttribute("aria-current", "true");
}
if (item === current) button.classList.add("today");
if (isDayDone(item)) button.classList.add("done");
if (dayAppeared(item) && !isDayDone(item)) button.classList.add("appeared");
if (item < current && !dayAppeared(item)) button.classList.add("late");
if (planFor(item).review) button.classList.add("review");
button.innerHTML = `
<span>${item}</span>
`;
button.addEventListener("click", () => {
state.selectedDay = item;
writeState();
transitionRender();
});
nodes.weekRoute.appendChild(button);
}
}
function renderExercises(day) {
const plan = planFor(day);
const checks = dayChecks(day);
nodes.exerciseList.innerHTML = "";
plan.exercises.forEach(([name, detail, note], index) => {
const item = document.createElement("li");
item.className = `exercise-item${checks[index] ? " done" : ""}`;
item.innerHTML = `
<button class="check-button" type="button" aria-label="切换 ${name} 完成状态" aria-pressed="${Boolean(checks[index])}">${checks[index] ? "✓" : ""}</button>
<div class="exercise-copy">
<h3 class="exercise-title-row">
<span class="exercise-name">${name}</span>
</h3>
<p class="exercise-detail">${detail}</p>
<small class="exercise-note">${note}</small>
</div>
`;
item.querySelector("button").addEventListener("click", () => {
const wasDone = isDayDone(day);
const next = [...dayChecks(day)];
next[index] = !next[index];
setDayChecks(day, next);
state.days[day] = next.filter(Boolean).length === plan.exercises.length;
writeState();
showToast(next[index] ? `${name} 已记录` : `${name} 已取消`);
transitionRender();
if (!wasDone && state.days[day]) openCompleteDialog();
});
nodes.exerciseList.appendChild(item);
});
}
function renderArchive(day) {
const current = currentPlanDay();
nodes.calendarGrid.innerHTML = "";
days.forEach((plan, index) => {
const item = index + 1;
const button = document.createElement("button");
button.type = "button";
button.className = "calendar-day";
button.textContent = item;
if (item === day) button.classList.add("selected");
if (isDayDone(item)) button.classList.add("done");
if (item < current && !dayAppeared(item)) button.classList.add("late");
button.addEventListener("click", () => {
state.selectedDay = item;
writeState();
transitionRender();
});
nodes.calendarGrid.appendChild(button);
});
}
function renderReview(day) {
const review = weeklyReview(day);
nodes.reviewPanel.hidden = !planFor(day).review;
nodes.reviewAppear.textContent = review.appeared;
nodes.reviewFull.textContent = review.full;
nodes.reviewMinimum.textContent = review.minimum;
renderReviewTable(review);
}
function render(options = {}) {
const day = state.selectedDay || currentPlanDay();
const plan = planFor(day);
const done = isDayDone(day);
const checked = checkedCount(day);
renderTop(day);
renderWeekRoute(day);
renderExercises(day);
renderArchive(day);
renderReview(day);
const actionText = completionActionText(day);
nodes.completionText.textContent = `${checked}/${plan.exercises.length}`;
nodes.completeDay.textContent = actionText;
nodes.completeDay.classList.toggle("done", done);
nodes.completeDay.setAttribute("aria-pressed", String(done));
nodes.navComplete.setAttribute("aria-label", actionText);
if (options.toast) showToast(options.toast);
writeState();
}
function transitionRender(options = {}) {
if (document.startViewTransition) {
document.startViewTransition(() => render(options));
return;
}
render(options);
}
function completionActionText(day) {
const checked = checkedCount(day);
const total = planFor(day).exercises.length;
if (isDayDone(day)) return "今天已完成";
if (checked > 0 && checked < total) return "今天到这";
return "完成今天";
}
function settleDay() {
const day = state.selectedDay;
state.settled = state.settled || {};
state.settled[day] = true;
state.selectedDay = Math.min(days.length, day + 1);
writeState();
transitionRender({ toast: "已按当前进度收摊" });
}
function completeDay() {
const day = state.selectedDay;
const checked = checkedCount(day);
const total = planFor(day).exercises.length;
if (isDayDone(day)) {
openCompleteDialog();
return;
}
if (checked > 0 && checked < total) {
settleDay();
return;
}
setDayChecks(day, planFor(day).exercises.map(() => true));
state.days[day] = true;
writeState();
transitionRender();
openCompleteDialog();
}
async function copyText(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const field = document.createElement("textarea");
field.value = text;
field.setAttribute("readonly", "");
field.style.position = "fixed";
field.style.top = "-1000px";
document.body.appendChild(field);
field.select();
document.execCommand("copy");
field.remove();
}
function showToast(message) {
if (!nodes.toast) return;
window.clearTimeout(toastTimer);
nodes.toast.textContent = message;
nodes.toast.classList.add("show");
toastTimer = window.setTimeout(() => {
nodes.toast.classList.remove("show");
}, 2200);
}
function finishLoading() {
if (!nodes.islandLoading) return;
nodes.islandLoading.classList.add("is-hidden");
nodes.islandLoading.setAttribute("aria-hidden", "true");
window.setTimeout(() => {
nodes.islandLoading.hidden = true;
}, 280);
}
function showLoadingError() {
if (nodes.islandLoadingText) nodes.islandLoadingText.textContent = "plan.json 加载失败";
if (nodes.islandLoading) nodes.islandLoading.classList.add("is-error");
}
function openCompleteDialog() {
nodes.completeDialog.hidden = false;
document.body.classList.add("modal-open");
nodes.closeComplete.focus();
}
function closeCompleteDialog() {
nodes.completeDialog.hidden = true;
document.body.classList.remove("modal-open");
nodes.completeDay.focus();
}
function openHandoffDialog() {
nodes.handoffDialog.hidden = false;
document.body.classList.add("modal-open");
nodes.painInput.focus();
}
function closeHandoffDialog() {
nodes.handoffDialog.hidden = true;
document.body.classList.remove("modal-open");
nodes.handoffTrigger.focus();
}
async function copyHandoff() {
const text = weeklyReview(state.selectedDay, {
pain: nodes.painInput.value,
resistance: nodes.resistanceInput.value
}).text;
await copyText(text);
closeHandoffDialog();
showToast("已复制,贴回对话给 Agent");
}
function openResetDialog() {
nodes.handoffDialog.hidden = true;
nodes.resetDialog.hidden = false;
document.body.classList.add("modal-open");
nodes.cancelReset.focus();
}
function closeResetDialog() {
nodes.resetDialog.hidden = true;
document.body.classList.remove("modal-open");
nodes.handoffTrigger.focus();
}
function resetPlan() {
state = createState();
writeState();
closeResetDialog();
transitionRender({ toast: "路线已重置,从第一天重新开始" });
}
function bindEvents() {
nodes.completeDay.addEventListener("click", () => completeDay());
nodes.navComplete.addEventListener("click", () => completeDay());
nodes.navPrev.addEventListener("click", () => {
state.selectedDay = Math.max(1, state.selectedDay - 1);
transitionRender();
});
nodes.navNext.addEventListener("click", () => {
state.selectedDay = Math.min(days.length, state.selectedDay + 1);
transitionRender();
});
nodes.handoffTrigger.addEventListener("click", () => openHandoffDialog());
nodes.closeComplete.addEventListener("click", () => closeCompleteDialog());
nodes.completeDialog.addEventListener("click", (event) => {
if (event.target === nodes.completeDialog) closeCompleteDialog();
});
nodes.copyReview.addEventListener("click", () => openHandoffDialog());
nodes.cancelHandoff.addEventListener("click", () => closeHandoffDialog());
nodes.copyHandoff.addEventListener("click", async () => {
try {
await copyHandoff();
} catch {
showToast("复制失败,请手动选中复盘文本");
}
});
nodes.handoffDialog.addEventListener("click", (event) => {
if (event.target === nodes.handoffDialog) closeHandoffDialog();
});
nodes.openResetFromHandoff.addEventListener("click", () => openResetDialog());
nodes.cancelReset.addEventListener("click", () => closeResetDialog());
nodes.confirmReset.addEventListener("click", () => resetPlan());
nodes.resetDialog.addEventListener("click", (event) => {
if (event.target === nodes.resetDialog) closeResetDialog();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !nodes.completeDialog.hidden) closeCompleteDialog();
if (event.key === "Escape" && !nodes.handoffDialog.hidden) closeHandoffDialog();
if (event.key === "Escape" && !nodes.resetDialog.hidden) closeResetDialog();
});
}
async function init() {
route = await loadJson("./plan.json?v=20260524-route");
days = loadDays(route);
state = readState();
if (state.lastSeen !== todayKey()) {
state.selectedDay = currentPlanDay();
state.lastSeen = todayKey();
}
state.selectedDay = Math.min(days.length, Math.max(1, state.selectedDay || currentPlanDay()));
bindEvents();
render();
finishLoading();
}
init().catch(() => {
showLoadingError();
});
assets/
L2 | 父级: ../CLAUDE.md
成员清单 animal-island/: Animal Island 原生视觉资产子模块,只保留页面 CSS 实际引用的背景、伙伴、光标、分隔线和海浪。 app.js: Fitness Island 静态应用行为目标,负责 Animal Loading、plan.json 渲染、本地状态、结算弹窗、周复盘和导出。 design.md: UI 组件契约参考,约束页面组件、Loading、字体 fallback/可选子集、token、状态映射和改动流程。 favicon.svg: 圆角动森训练岛 favicon,来自用户提供的 SVG,经 clipPath 圆角裁切。 index.html: Fitness Island 静态页 canonical 页面目标,保留 L3 契约、Animal Loading、hero、路线卡、今日任务、结算按钮、复盘 Table、导出弹窗、Footer sea 和底部导航骨架。 plan.example.json: 可运行 plan.json 示例,展示默认路线结构,被 init_project.sh 复制为初始 plan.json 后由 agent 按用户情况改写。 styles.css: Fitness Island 视觉层目标,承接 animal-island-ui token、Loading、系统 fallback 字体栈、布局、动效、移动端首屏和底部导航样式。
架构决策 assets/ 保存生成时逐字锁定使用的页面目标,不保存记忆模板或完整 TTF/OTF/WOFF2 字体包。index.html 是页面骨架真相源;styles.css 和 app.js 是可运行体验目标;design.md 是 UI 规则参考;favicon.svg 是网页身份图标;plan.example.json 是数据格式示例,不是用户最终训练真相;animal-island/ 只保留 CSS 实际引用的视觉资产;verify_assets.py 保证生成项目与这些 canonical 资产同构;全局共享记忆和 fitness-plan.md 由 SKILL.md 协议和 init_project.sh 直接创建。
变更日志 2026-06-10: 明确 index/styles/app/design/favicon/animal-island 为默认生成项目的逐字锁定资产,由 verify_assets.py 校验。 2026-05-25: 移除默认页面对不存在字体子集的 preload/@font-face 依赖,字体策略改为 fallback 先跑通、生产子集后启用。 2026-05-25: index/styles/app/design 同步统一 hero-stage 舞台并加入 Animal Loading 启动遮罩。 2026-05-25: 新增圆角 favicon.svg,并将网页默认标题改为「动森训练岛」。 2026-05-25: 独立仓库化,父级链接改为相对路径。 2026-05-25: 新增 plan.example.json,作为 init_project.sh 播种的可运行路线示例。 2026-05-25: 新增 styles.css、app.js、design.md 和 animal-island/,作为生成项目时可复制/改写的参考目标。 2026-05-25: 删除 MEMORY.md,assets 只保留页面目标。 2026-05-25: 历史 MEMORY.md 已删除,全局共享记忆改由 SKILL.md 协议和 init_project.sh 直接创建。 2026-05-24: 删除未被脚本消费的 font-profiles.json,保留可生成产物直接使用的资产。 2026-05-24: 新增 index.html,作为每次生成 Fitness Island 静态页时使用的 canonical 页面目标。
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
<!-- [INPUT]: 依赖 animal-island-ui v0.9.3 的 Card/Button/Checkbox/Collapse/Modal/Footer/Cursor 组件规律、assets/animal-island 原生资产、plan.json 训练路线和当前静态 HTML/CSS/JS 边界 [OUTPUT]: 对外提供 Fitness Island 静态打卡页的组件设计宪法、字体防缺字规则、组件清单、状态映射和改动流程 [POS]: fitness-island/assets 的 UI 设计参考,约束 index.html、styles.css 和 app.js 只能从组件出发演化 [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md -->
Fitness Island Design
设计目标:个人训练页不是营销页,是每天打开一次的打卡器。界面必须像 animal-island-ui 的组件组合,而不是临场拼装的装饰画。
铁律
1. 先组件,后页面。任何新 UI 先落到下面的组件清单,再写 HTML/CSS/JS。 2. 不发明相似组件。已有 Button、Card、Checkbox、Collapse、Modal、Footer、Cursor 能表达,就复用现有形态。 3. 文案只服务动作。状态能靠颜色、位置、aria-label 表达时,不加第二层可见文字。 4. 字体分两档:默认页面用系统 fallback 跑通;生产包有子集文件时才启用本地圆角字体。 5. 每次改 UI 后,用浏览器看目标区域,检查缺字、截断、重叠和移动端宽度。
字体
CSS 统一使用 --animal-font-family。
优先级: Fitness Rounded CN -> Resource Han Rounded CN -> Nunito -> Noto Sans SC -> Zen Maru Gothic -> HarmonyOS Sans SC -> MiSans -> sans-serif。
默认模板不 preload、不声明不存在的 WOFF2。生成 assets/fonts 后,才加入 @font-face 和可选 preload。
生产字体策略:
- 扫描
index.html、app.js、plan.json、fitness-plan.md和用户文案。 - 按实际文字生成 WOFF2 子集,不放完整字体包。
- 发布前跑 glyph check;缺字就扩大子集。
- WOFF2 子集按
400/500/600/700/800/900分别声明@font-face。
Tokens
颜色、间距、圆角只从 styles.css :root 的 --animal-* token 取。
核心语义:
--animal-primary-color: 已出现、已完成、确认状态。--animal-focus-yellow: 今天、主行动、焦点状态。--animal-error-color: 逾期、风险、重置。--animal-bg-content: 卡片底色。--animal-text-color: 标题和强信息。--animal-text-color-muted: 备注和轻提示。
间距阶梯:
--animal-stack-gap: 普通组件之间的垂直距离,固定 16px。--animal-stack-gap-raised: 带 3D 阴影组件之后的距离,固定 22px,阴影也算布局占用。--animal-surface-pad-x/y: 外层 surface 的水平/垂直内边距,桌面 24px/18px,移动端只按断点整体收缩。--animal-inset-gap: 外壳到内层主按钮的距离,固定 14px。- 列表内部 gap 使用 14px;小于 8px 只允许在 icon 内部使用。
圆角阶梯:
--animal-radius-base: 16px,用于小按钮、输入、信息块。--animal-radius-lg: 22px,用于节点、轻量卡片。--animal-radius-card: 28px,用于任务卡。--animal-radius-surface: 32px,用于 RouteCard、CompleteStrip、ArchiveCollapse 这类外层 surface。--animal-radius-pill: 999px,只用于真正的胶囊按钮和圆形控件。
禁止写 34px、40px 35px 45px 这类临场半径。Animal 的有机感来自阴影、贴图和 clip-path,不来自随机圆角。
Components
AppShell
页面外框。只负责最大宽度、底部导航安全距离和背景层级。
IslandBackground
全局背景使用 animal-island-ui 原生 home-bg.webp。纹理尺寸固定为 1120px auto,滚动周期固定为 48s;禁止把叶子纹理调回密集小图。
Loading
启动遮罩。只在 plan.json 加载期间出现,使用 animal-island-ui Loading 的同一套游戏化反馈原则:中心伙伴、短状态文案、条纹进度条和 3D 阴影。加载成功后淡出并从可访问树隐藏;加载失败时停留在当前层显示 plan.json 加载失败,不露出半初始化页面。
Hero
首页标题区。包含页面标题、日期打字机、游戏提示语、进度条和伙伴贴图。Hero 不是卡片,不加边框。
Hero 使用统一 hero-stage 舞台构图:标题固定左上,日期和提示语固定左下,ProgressMeter 固定左下更低处,hero-animal 固定右侧。桌面和移动端共享同一套位置关系,只允许尺寸通过 clamp() 和容器宽度缩放;禁止再写移动端 grid 重排或桌面伪元素伙伴贴图。
ProgressMeter
今日进度条。文字只显示计数,例如 0/5。不在进度条旁边再解释含义。
RouteCard
本周路线容器。顶部保留周信息和右侧状态 pill;下面只放 RouteNode。
RouteNode
7 日路线节点。可见内容只保留日期数字,不显示 今天/出现/待做/未完成。状态通过 class 表达:
selected: 当前查看日,黄色底。today: 今天,数字圆点黄色。done: 完成,薄荷底和 teal 圆点。appeared: 出现过,teal 圆点。late: 已过期未出现,暖红边框。
无障碍语义放在 aria-label,不要把状态文字塞回视觉层。
TaskCard
训练动作卡。左侧 Checkbox,右侧动作名、参数 pill、备注。完成态只改底色、边框和 checkbox,不改文案。
文字层级:
- 动作名最大,移动端约 23px。
- 参数 pill 居中,移动端约 18px,必须小于动作名、大于备注。
- 备注最轻,移动端约 15px。
CompleteStrip
黄色主行动卡。它是唯一结算入口,按钮下不放解释文字。 外壳用 --animal-radius-surface,内距用 --animal-inset-gap,和下一个卡片之间用 --animal-stack-gap-raised。
按钮文案:
0/total:完成今天1..total-1/total:今天到这total/total:完成今天- 已完整结算后:
今天已完成
语义:
完成今天: 勾满当前日所有动作,并触发CompleteRewardModal。今天到这: 按当前勾选进度收摊并进入下一天,不补全剩余动作,不触发完整完成奖励。
CompleteRewardModal
每日完成后的奖励弹窗。结构固定为 OK 奖励章、短标题 盖章啦、主按钮 收下。
规则:
- 不放 caption。
- 不放解释段落。
- 不写第二句鼓励文案。
收下必须是 Animal Button primary 物理按钮:50px 胶囊、黄色背景、底部 3D shadow、hover 上浮 1px、active 下压 2px。- 惊喜感来自奖励章弹出动画和按钮按压,不来自大段文字。
ReviewTable
周日复盘只用 Table 展示,不用深色代码块展示 Markdown。
结构:
- columns:
项目、结果、记录。 - rows:
周期、完成、最稳、最弱、连续。 完成的记录列使用 Day chip,done/appeared/missed通过颜色表达。
规则:
- 屏幕显示用 table rows,复制给 Agent 继续用 Markdown 文本。
- Table 外壳使用
--animal-bg-content、6px padding、--animal-radius-card。 - 行分隔使用 animal-island-ui Table 的虚线 divider。
- 禁止再把周复盘可见区域做成
<pre>代码块。
ArchiveCollapse
30 天全图。使用 Collapse/Card/Divider 组合,只有一行入口;禁止再加右侧二级查看按钮。 外壳圆角必须和 CompleteStrip 同级,避免一个 32px 一个 24px 的视觉断层。
FloatingNav
底部三键导航:前一天、完成今天、后一天。固定 3 个按钮,不扩展到 4 个以上。
Modal
完成、导出、重置都使用 Animal Modal clip-path。Modal 内只保留一个主动作和必要取消动作。
Modal 按钮来自 animal-island-ui:
- default:
box-shadow: 0 5px 0 0 #bdaea0 - hover:
box-shadow: 0 6px 0 0 #bdaea0; transform: translateY(-1px) - active:
box-shadow: 0 1px 0 0 #bdaea0; transform: translateY(2px)
Toast
短反馈。只用于动作结果,不承载说明文档。
Change Flow
1. 先改 design.md,把组件或状态写清楚。 2. 再改 index.html 的组件骨架。 3. 再改 styles.css 的组件样式。 4. 最后改 app.js 的状态渲染。 5. 回头检查 L3 头部和 CLAUDE.md / AGENTS.md 成员清单。
禁止
- 禁止在
app.js里拼出新的视觉结构后再补 CSS。 - 禁止为一个状态增加三种视觉表达。
- 禁止给 RouteNode 加可见状态文字。
- 禁止默认引用不存在的字体文件;有
assets/fonts子集后再启用本地圆角字体。 - 禁止新增没有组件名的 class。
<!--
[INPUT]: 依赖 app.js 加载的 plan.json 训练路线、localStorage 状态、可选生产 assets/fonts 字体子集、assets/animal-island 原生背景/伙伴/光标资产和 styles.css 的滚动背景与 animal-island-ui 静态投影
[OUTPUT]: 对外提供「动森训练岛」首页式标题、favicon、统一 hero-stage 舞台、Animal Loading 启动遮罩、打字机日程行、游戏提示语、hero-animal 伙伴层、今日进度条、零基础 30 天训练打卡入口、7 日路线、今日动作序列、黄色结算主按钮、导出入口、奖励式每日完成弹窗、周日复盘 Table、30 天全图 Collapse、本周交接导出弹窗、重置弹窗、Animal Modal clip-path、Footer sea 装饰与反馈容器
[POS]: fitness-island/assets 的 canonical 页面目标,被 init_project.sh 复制到目标项目 index.html,把 animal-island-ui 设计系统和一屏一意图训练流投射为 HTML 骨架
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
-->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f7f1e3">
<title>动森训练岛</title>
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="./styles.css?v=20260524-exercise-scale">
</head>
<body>
<div class="island-loading" id="islandLoading" role="status" aria-live="polite">
<div class="island-loading-stage">
<div class="island-loading-animal" aria-hidden="true"></div>
<p id="islandLoadingText">小岛载入中</p>
<div class="island-loading-bar" aria-hidden="true"><span></span></div>
</div>
</div>
<svg class="animal-modal-defs" style="position:absolute;width:0;height:0" aria-hidden="true">
<defs>
<clipPath id="animal-modal-clip" clipPathUnits="objectBoundingBox">
<path d="M0.501,0.005 L0.501,0.005 L0.523,0.005 L0.549,0.006 C0.704,0.01,0.796,0.017,0.825,0.027 L0.827,0.028 C0.872,0.045,0.939,0.044,0.978,0.17 C1,0.254,1,0.365,0.99,0.505 L0.988,0.513 C0.979,0.558,0.971,0.598,0.965,0.633 C0.956,0.689,0.979,0.77,0.964,0.865 C0.953,0.928,0.921,0.966,0.869,0.979 C0.821,0.986,0.773,0.992,0.726,0.995 L0.712,0.996 L0.694,0.997 C0.648,1,0.586,1,0.507,1 L0.501,1 L0.464,1 C0.385,1,0.325,0.998,0.283,0.995 C0.234,0.992,0.184,0.987,0.133,0.979 C0.081,0.966,0.05,0.928,0.039,0.865 C0.023,0.77,0.047,0.689,0.037,0.633 C0.031,0.595,0.023,0.552,0.013,0.505 C-0.006,0.365,-0.002,0.254,0.024,0.17 C0.064,0.045,0.13,0.045,0.174,0.028 L0.175,0.028 C0.204,0.017,0.303,0.009,0.474,0.005 L0.501,0.005"></path>
</clipPath>
</defs>
</svg>
<main class="app-shell">
<header class="topbar" aria-label="今日训练">
<div class="week-plant" id="weekPlant" aria-hidden="true"></div>
<div class="hero-stage">
<h1 id="topTitle">动森训练岛</h1>
<p class="caption hero-meta" id="topMeta">第 1 天</p>
<p class="body-copy" id="topSummary">先散个步,再看身体愿不愿意多做一点。</p>
<div class="hero-animal" aria-hidden="true"></div>
<div class="hero-progress" aria-label="今日完成进度">
<span id="heroProgressFill"></span>
<strong id="heroProgressText">0/1</strong>
</div>
</div>
<button class="small-button top-copy-button" type="button" id="handoffTrigger" aria-label="导出记录给 Agent">导出</button>
</header>
<section class="route-card" aria-labelledby="weekTitle">
<div class="section-head">
<div>
<p class="caption" id="weekLabel">第 1 周</p>
<h2 id="weekTitle">这周路线</h2>
</div>
<p class="caption strong" id="weekSignal">路线全灰,亮一颗。</p>
</div>
<div class="week-route" id="weekRoute" aria-label="本周 7 日路线"></div>
</section>
<section class="route-card today-card" aria-labelledby="sequenceTitle">
<div class="section-head">
<div>
<p class="caption" id="phaseLabel">习惯启动</p>
<h2 id="sequenceTitle">今日顺序</h2>
</div>
<p class="caption strong" id="completionText">0/1</p>
</div>
<ol class="exercise-list" id="exerciseList"></ol>
</section>
<section class="complete-strip" aria-label="结算今日训练">
<button class="complete-button" id="completeDay" type="button">完成今天</button>
</section>
<section class="review-card" id="reviewPanel" aria-labelledby="reviewTitle" hidden>
<div class="section-head">
<div>
<p class="caption">周日回顾</p>
<h2 id="reviewTitle">这周交接</h2>
</div>
<button class="small-button" id="copyReview" type="button">导出</button>
</div>
<div class="review-stats" aria-label="本周训练摘要">
<div>
<span id="reviewAppear">0</span>
<small>出现</small>
</div>
<div>
<span id="reviewFull">0</span>
<small>完整</small>
</div>
<div>
<span id="reviewMinimum">0</span>
<small>兜底</small>
</div>
</div>
<div class="review-table-wrap" id="reviewTable" aria-label="本周复盘表格"></div>
</section>
<details class="archive-card">
<summary>
<span class="archive-summary-main">
<span class="archive-toggle-icon" aria-hidden="true"></span>
<span class="archive-title-copy">
<span>30 天全图</span>
<small>训练岛地图册</small>
</span>
</span>
<span class="archive-leaf" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false">
<path fill="currentColor" d="M17,8C8,10 5.9,16.17 3.82,21.34L5.71,22L6.66,19.7C7.14,19.87 7.64,20 8,20C19,20 22,3 22,3C21,5 14,5.25 9,6.25C4,7.25 2,11.5 2,13.5C2,15.5 3.75,17.25 3.75,17.25C7,8 17,8 17,8Z"></path>
</svg>
</span>
</summary>
<div class="archive-panel">
<div class="archive-divider" aria-hidden="true"></div>
<div class="calendar-grid" id="calendarGrid" aria-label="30 天完成状态"></div>
</div>
</details>
</main>
<div class="animal-footer-sea" aria-hidden="true"></div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="modal-backdrop" id="completeDialog" hidden>
<section class="modal-card complete-modal-card" role="dialog" aria-modal="true" aria-labelledby="completeTitle">
<div class="stamp-mark" aria-hidden="true">OK</div>
<h2 id="completeTitle">盖章啦</h2>
<div class="modal-actions single">
<button type="button" class="reward-button" id="closeComplete">收下</button>
</div>
</section>
</div>
<div class="modal-backdrop" id="handoffDialog" hidden>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="handoffTitle">
<p class="caption">给 Agent</p>
<h2 id="handoffTitle">导出记录</h2>
<p class="body-copy">复制一段本周记录,贴回对话里让我更新下一周。空着就不写进 Markdown。</p>
<label class="field-label" for="painInput">
<span>疼痛 / 不适</span>
<textarea id="painInput" rows="3" autocomplete="off"></textarea>
</label>
<label class="field-label" for="resistanceInput">
<span>主要阻力</span>
<textarea id="resistanceInput" rows="3" autocomplete="off"></textarea>
</label>
<div class="modal-actions">
<button type="button" class="small-button ghost" id="cancelHandoff">取消</button>
<button type="button" class="small-button primary" id="copyHandoff">复制</button>
</div>
<button type="button" class="danger-link" id="openResetFromHandoff">重置本地记录</button>
</section>
</div>
<div class="modal-backdrop" id="resetDialog" hidden>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="resetTitle">
<p class="caption">重置</p>
<h2 id="resetTitle">从今天重新开始?</h2>
<p class="body-copy">这会清空本地完成记录,但不会改变训练计划。</p>
<div class="modal-actions">
<button type="button" class="small-button ghost" id="cancelReset">取消</button>
<button type="button" class="small-button danger" id="confirmReset">确认重置</button>
</div>
</section>
</div>
<nav class="floating-nav" aria-label="训练操作">
<button type="button" id="navPrev" aria-label="前一天">‹</button>
<button class="nav-fab" type="button" id="navComplete" aria-label="完成今天">✓</button>
<button type="button" id="navNext" aria-label="后一天">›</button>
</nav>
<script src="./app.js?v=20260524-rounded-font"></script>
</body>
</html>
{
"contract": {
"INPUT": "依赖用户稳定默认值、低重量原则和 4 周 + 2 天缓冲节奏",
"OUTPUT": "对外提供示例 30 天训练路线、周主题、视觉信号、复盘日和最低承诺缓冲日",
"POS": "fitness-island/assets 的 plan.json 示例,被 init_project.sh 复制为可运行默认路线,再由 agent 按用户情况改写",
"PROTOCOL": "[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md"
},
"weeks": [
{
"theme": "出现",
"signal": "路线全灰,亮一颗。",
"sentence": "今天来岛上走走。",
"plant": "sprout",
"days": [
{
"type": "A 训练",
"title": "背 + 核心",
"phase": "力量入门",
"summary": "先上跑步机 5 分钟,再做低重量背部和核心。",
"minutes": 10,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["高位下拉", "20kg,2 组 x 10 次", "保留 3 次余力"],
["低位划船", "20kg,2 组 x 10 次", "背挺直"],
["腹部卷腹", "10-15kg,2 组 x 12 次", "慢一点"],
["跑步机", "4.0-4.5 km/h,走 5 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "习惯加固",
"summary": "今天只走路,不加力量,给身体留恢复空间。",
"minutes": 12,
"exercises": [
["跑步机", "4.0-4.5 km/h,走 10-12 分钟", "能完整说话"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "B 训练",
"title": "肩 + 后三角",
"phase": "力量入门",
"summary": "肩部只练轻重量,动作干净比重量重要。",
"minutes": 10,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["肩推", "10kg,2 组 x 8 次", "不锁死肘"],
["侧平举", "5kg,2 组 x 12 次", "不耸肩"],
["坐姿划船/后三角", "15kg,2 组 x 10 次", "肩胛后收"],
["跑步机", "4.0-4.5 km/h,走 5 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "恢复",
"summary": "今天用低强度走路恢复,别把恢复日练成惩罚日。",
"minutes": 12,
"exercises": [
["跑步机", "4.0-4.5 km/h,走 10-12 分钟", "轻松完成"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "C 训练",
"title": "全身轻循环",
"phase": "动作熟悉",
"summary": "每个动作只做 1 组,熟悉器械顺序。",
"minutes": 10,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["高位下拉", "20kg,1 组 x 10 次", "慢拉慢放"],
["低位划船", "20kg,1 组 x 10 次", "背挺直"],
["背伸", "10kg,1 组 x 12 次", "腰不疼才做"],
["腹部卷腹", "10-15kg,1 组 x 12 次", "慢一点"],
["肩推", "10kg,1 组 x 8 次", "不耸肩"],
["跑步机", "4.0-4.5 km/h,走 5 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "心肺入门",
"summary": "稍微走久一点,但仍然不跑。",
"minutes": 15,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 12-15 分钟", "只走不跑"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "最低承诺日",
"title": "最低承诺日",
"phase": "身份维护",
"summary": "今天只守住 5 分钟。不是休息失败,是习惯成功。",
"minutes": 5,
"review": true,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "盖个小章"]
]
}
]
},
{
"theme": "加入",
"signal": "走路日变长一点。",
"sentence": "只加一点,不加压力。",
"plant": "leaf",
"days": [
{
"type": "A 训练",
"title": "背 + 核心",
"phase": "力量入门",
"summary": "力量不加码,先把出现变稳定。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["高位下拉", "20kg,2 组 x 10 次", "保留 3 次余力"],
["低位划船", "20kg,2 组 x 10 次", "背挺直"],
["腹部卷腹", "10-15kg,2 组 x 12 次", "慢一点"],
["跑步机", "4.0-4.5 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "习惯加固",
"summary": "走路时间略长,仍然能完整说话。",
"minutes": 15,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 15 分钟", "能完整说话"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "B 训练",
"title": "肩 + 后三角",
"phase": "动作稳定",
"summary": "肩部仍然轻,先稳住动作轨迹。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["肩推", "10kg,2 组 x 8 次", "不锁死肘"],
["侧平举", "5kg,2 组 x 12 次", "不耸肩"],
["坐姿划船/后三角", "15kg,2 组 x 10 次", "肩胛后收"],
["跑步机", "4.0-4.5 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "恢复",
"summary": "恢复日只负责让明天更容易出现。",
"minutes": 15,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 15 分钟", "轻松完成"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "C 训练",
"title": "全身轻循环",
"phase": "顺序熟悉",
"summary": "还是低重量,目标是不用重新思考。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["高位下拉", "20kg,1 组 x 10 次", "慢拉慢放"],
["低位划船", "20kg,1 组 x 10 次", "背挺直"],
["背伸", "10kg,1 组 x 12 次", "腰不疼才做"],
["腹部卷腹", "10-15kg,1 组 x 12 次", "慢一点"],
["肩推", "10kg,1 组 x 8 次", "不耸肩"],
["跑步机", "4.0-4.5 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "心肺入门",
"summary": "走久一点,但不追速度。",
"minutes": 18,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 15-18 分钟", "只走不跑"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "最低承诺日",
"title": "最低承诺日",
"phase": "身份维护",
"summary": "今天只守住 5 分钟,然后复盘。",
"minutes": 5,
"review": true,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "盖个小章"]
]
}
]
},
{
"theme": "稳住",
"signal": "重量节点只记录,不硬加。",
"sentence": "稳定比用力更高级。",
"plant": "branch",
"days": [
{
"type": "A 训练",
"title": "背 + 核心",
"phase": "重量观察",
"summary": "如果全程无痛且轻松,记录下周是否可加一点。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["高位下拉", "20kg,2 组 x 10 次", "无痛才记录可加"],
["低位划船", "20kg,2 组 x 10 次", "背挺直"],
["腹部卷腹", "10-15kg,2 组 x 12 次", "慢一点"],
["跑步机", "4.0-4.8 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "稳态心肺",
"summary": "保持呼吸快一点,但还能完整说话。",
"minutes": 18,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 18 分钟", "能完整说话"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "B 训练",
"title": "肩 + 后三角",
"phase": "重量观察",
"summary": "肩部不急着加重量,先保持干净。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["肩推", "10kg,2 组 x 8 次", "无痛才记录可加"],
["侧平举", "5kg,2 组 x 12 次", "不耸肩"],
["坐姿划船/后三角", "15kg,2 组 x 10 次", "肩胛后收"],
["跑步机", "4.0-4.8 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "恢复",
"summary": "低强度恢复,给身体留余地。",
"minutes": 18,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 18 分钟", "轻松完成"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "C 训练",
"title": "全身轻循环",
"phase": "顺序稳定",
"summary": "不追泵感,追动作不乱。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["高位下拉", "20kg,1 组 x 10 次", "慢拉慢放"],
["低位划船", "20kg,1 组 x 10 次", "背挺直"],
["背伸", "10kg,1 组 x 12 次", "腰不疼才做"],
["腹部卷腹", "10-15kg,1 组 x 12 次", "慢一点"],
["肩推", "10kg,1 组 x 8 次", "不耸肩"],
["跑步机", "4.0-4.8 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "稳态心肺",
"summary": "走路保持,不跑步。",
"minutes": 20,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 18-20 分钟", "只走不跑"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "最低承诺日",
"title": "最低承诺日",
"phase": "身份维护",
"summary": "今天只守住 5 分钟,然后复盘。",
"minutes": 5,
"review": true,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "盖个小章"]
]
}
]
},
{
"theme": "巩固",
"signal": "路线尾端看见 next。",
"sentence": "这是系统,不是冲刺。",
"plant": "tree",
"days": [
{
"type": "A 训练",
"title": "背 + 核心",
"phase": "巩固",
"summary": "照旧完成,不用证明更多。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["高位下拉", "20kg,2 组 x 10 次", "保留 3 次余力"],
["低位划船", "20kg,2 组 x 10 次", "背挺直"],
["腹部卷腹", "10-15kg,2 组 x 12 次", "慢一点"],
["跑步机", "4.0-4.8 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "巩固",
"summary": "让走路变成不需要谈判的默认动作。",
"minutes": 20,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 20 分钟", "能完整说话"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "B 训练",
"title": "肩 + 后三角",
"phase": "巩固",
"summary": "动作保持,结束后留一点余力。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["肩推", "10kg,2 组 x 8 次", "不锁死肘"],
["侧平举", "5kg,2 组 x 12 次", "不耸肩"],
["坐姿划船/后三角", "15kg,2 组 x 10 次", "肩胛后收"],
["跑步机", "4.0-4.8 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "恢复",
"summary": "恢复日保持轻松,不把身体推到疼。",
"minutes": 20,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 20 分钟", "轻松完成"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "C 训练",
"title": "全身轻循环",
"phase": "巩固",
"summary": "把路线走顺,30 天后再决定下一步。",
"minutes": 12,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "入口"],
["高位下拉", "20kg,1 组 x 10 次", "慢拉慢放"],
["低位划船", "20kg,1 组 x 10 次", "背挺直"],
["背伸", "10kg,1 组 x 12 次", "腰不疼才做"],
["腹部卷腹", "10-15kg,1 组 x 12 次", "慢一点"],
["肩推", "10kg,1 组 x 8 次", "不耸肩"],
["跑步机", "4.0-4.8 km/h,走 7 分钟", "收尾"]
]
},
{
"type": "走路日",
"title": "走路日",
"phase": "巩固",
"summary": "最后一个完整走路日,不跑步。",
"minutes": 20,
"exercises": [
["跑步机", "4.0-4.8 km/h,走 20 分钟", "只走不跑"],
["记录", "写下今天完成分钟数", "不断链"]
]
},
{
"type": "最低承诺日",
"title": "最低承诺日",
"phase": "身份维护",
"summary": "今天只守住 5 分钟,然后复盘。",
"minutes": 5,
"review": true,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "盖个小章"]
]
}
]
}
],
"buffer": {
"theme": "缓冲",
"signal": "只走最低承诺。",
"sentence": "不补课,不惩罚。",
"plant": "seed",
"days": [
{
"type": "缓冲日",
"title": "最低承诺",
"phase": "缓冲",
"summary": "今天不加训练量,只守住 5 分钟。",
"minutes": 5,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "盖个小章"]
]
},
{
"type": "缓冲日",
"title": "30 天收口",
"phase": "复盘",
"summary": "只走最低承诺,然后决定下一周。",
"minutes": 5,
"review": true,
"exercises": [
["跑步机", "4.0 km/h,坡度 0,走 5 分钟", "盖个小章"]
]
}
]
}
}
/*
[INPUT]: 依赖 design.md 的组件契约、index.html 的路线语义结构、assets/animal-island 的 animal-island-ui 原生背景/伙伴/光标资产和 app.js 写入的 selected/done/appeared/late 状态类
[OUTPUT]: 对外提供严格贴合 animal-island-ui v0.9.3 token 的移动优先训练岛界面、圆润中文系统字体栈、可选本地字体子集入口、规范化间距/圆角阶梯、清晰标题/参数/备注层级、Card/Button/Checkbox/Collapse/Loading/Modal/Footer/Table/Toast 静态 HTML 投影、小尺寸游戏光标、1120px 疏纹滚动叶子背景、统一 Hero Artboard 舞台、hero-animal 伙伴层、今日游戏进度条、无文字状态标签路线节点、黄色结算主按钮、奖励式完成弹窗、薄荷完成态、暖红逾期态、前景海底条和三键底部导航
[POS]: fitness-island/assets 的视觉层目标,只负责把 animal-island-ui 设计系统落实为可触控界面
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
*/
:root { color-scheme: light; --animal-primary-color: #19c8b9; --animal-primary-color-hover: #3dd4c6; --animal-primary-color-active: #50b9ab; --animal-primary-color-bg: #e6f9f6; --animal-success-color: #6fba2c; --animal-success-color-active: #5a9e1e; --animal-warning-color: #f5c31c; --animal-warning-color-active: #dba90e; --animal-error-color: #e05a5a; --animal-error-color-active: #c94444; --animal-text-color: #794f27; --animal-text-color-body: #725d42; --animal-text-color-secondary: #9f927d; --animal-text-color-muted: #8a7b66; --animal-text-color-disabled: #c4b89e; --animal-border-color: #aaa69d; --animal-border-color-hover: #827157; --animal-border-color-light: #e8e2d6; --animal-input-border: #c4b89e; --animal-input-border-hover: #a89878; --animal-bg-color: #f8f8f0; --animal-bg-content: rgb(247, 243, 223); --animal-bg-card-title: #fdfdf5; --animal-bg-secondary: #f0e8d8; --animal-bg-disabled: #f0ece2; --animal-focus-yellow: #ffcc00; --animal-focus-yellow-dark: #e0b800; --animal-shadow-btn: #bdaea0; --animal-shadow-input: #d4c9b4; --animal-shadow-card: rgba(107, 92, 67, 0.42); --animal-font-family: "Fitness Rounded CN", "Resource Han Rounded CN", Nunito, "Noto Sans SC", "Zen Maru Gothic", "HarmonyOS Sans SC", "MiSans", sans-serif; --animal-font-size-sm: 12px; --animal-font-size-base: 14px; --animal-font-size-lg: 16px; --animal-line-height-base: 1.5715; --animal-spacing-xs: 4px; --animal-spacing-sm: 8px; --animal-spacing-md: 12px; --animal-spacing-lg: 16px; --animal-spacing-xl: 24px; --animal-stack-gap: 16px; --animal-stack-gap-raised: 22px; --animal-surface-pad-x: 24px; --animal-surface-pad-y: 18px; --animal-inset-gap: 14px; --animal-radius-sm: 12px; --animal-radius-base: 16px; --animal-radius-lg: 22px; --animal-radius-card: 28px; --animal-radius-surface: 32px; --animal-radius-pill: 999px; --animal-motion-fast: 0.15s; --animal-motion: 0.25s cubic-bezier(0.4, 0, 0.2, 1); --display: 38px; --body: 16px; --caption: 12px; }
*,
*::before,
*::after { box-sizing: border-box; }
::view-transition-old(root),
::view-transition-new(root) { animation-duration: 180ms; animation-timing-function: ease; }
html { min-width: 320px; background: var(--animal-bg-color); }
body { margin: 0; min-height: 100dvh; overflow-x: hidden; background: url("./assets/animal-island/home-bg.webp") center top / 1120px auto repeat, #7dc395; color: var(--animal-text-color-body); cursor: url("./assets/animal-island/cursor-icon-small.png") 4 0, auto; font-family: var(--animal-font-family); font-size: var(--body); font-weight: 500; line-height: var(--animal-line-height-base); animation: island-bg-scroll 48s linear infinite; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
body.modal-open { overflow: hidden; }
button { min-width: 44px; min-height: 44px; border: 0; color: inherit; cursor: url("./assets/animal-island/cursor-icon-small.png") 4 0, pointer; font: inherit; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
button:focus-visible,
summary:focus-visible,
textarea:focus-visible { outline: 2px solid var(--animal-focus-yellow); outline-offset: 2px; }
h1,
h2,
h3,
p { margin: 0; }
h1 { max-width: 9em; margin-top: 4px; color: var(--animal-text-color); font-size: var(--display); font-weight: 800; letter-spacing: 0.02em; line-height: 1.1; }
h2 { color: var(--animal-text-color); font-size: 22px; font-weight: 900; letter-spacing: 0; line-height: 1.16; }
h3 { color: var(--animal-text-color-body); font-size: var(--body); font-weight: 800; letter-spacing: 0; line-height: 1.32; }
.app-shell { width: 100%; max-width: 720px; margin: 0 auto; padding: 18px 14px 132px; }
.caption { color: var(--animal-text-color-secondary); font-size: var(--caption); font-weight: 700; letter-spacing: 0.01em; line-height: 1.35; }
.caption.strong { color: var(--animal-primary-color); }
.body-copy { margin-top: 8px; color: var(--animal-text-color-muted); font-size: var(--body); line-height: 1.65; }
.topbar,
.route-card,
.complete-strip,
.review-card,
.archive-card { color: var(--animal-text-color-body); transition: all 0.3s ease; }
.route-card,
.complete-strip,
.review-card,
.archive-card { width: 100%; max-width: 100%; margin-top: var(--animal-stack-gap); border-radius: var(--animal-radius-surface); background: var(--animal-bg-content); padding: var(--animal-surface-pad-y) var(--animal-surface-pad-x); box-shadow: 0 4px 10px var(--animal-shadow-card); }
.route-card:hover,
.review-card:hover,
.archive-card:hover { transform: translateY(-2px); }
.island-loading { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; background: url("./assets/animal-island/home-bg.webp") center top / 1120px auto repeat, #7dc395; color: var(--animal-text-color); padding: 24px; transition: opacity 260ms ease, visibility 260ms ease; }
.island-loading.is-hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.island-loading-stage { display: grid; width: min(100%, 320px); justify-items: center; gap: 14px; border: 3px solid #d8d0c3; border-radius: var(--animal-radius-surface); background: rgba(255, 253, 245, 0.94); padding: 24px 26px 26px; box-shadow: 0 7px 0 0 var(--animal-shadow-btn), 0 16px 28px rgba(61, 52, 40, 0.18); }
.island-loading-animal { width: 148px; height: 102px; background: url("./assets/animal-island/animal-icon.png") center / contain no-repeat; animation: island-loading-bob 1200ms ease-in-out infinite; }
.island-loading p { color: var(--animal-text-color); font-size: 18px; font-weight: 900; line-height: 1.2; }
.island-loading-bar { position: relative; width: 100%; height: 18px; overflow: hidden; border: 2px solid #d4c9b4; border-radius: var(--animal-radius-pill); background: var(--animal-bg-content); box-shadow: inset 0 -2px 0 rgba(122, 91, 52, 0.08); }
.island-loading-bar span { position: absolute; inset: 0 auto 0 0; width: 52%; border-radius: inherit; background: repeating-linear-gradient(-45deg, #19c8b9, #19c8b9 10px, #11b7aa 10px, #11b7aa 20px); animation: island-loading-sweep 1350ms ease-in-out infinite; }
.island-loading.is-error .island-loading-animal { animation: none; opacity: 0.82; }
.island-loading.is-error .island-loading-bar span { width: 100%; background: var(--animal-error-color); animation: none; }
.topbar { position: relative; width: 100vw; max-width: none; height: clamp(336px, 54vw, 720px); margin: -18px 0 18px calc(50% - 50vw); overflow: hidden; border-radius: 0; background: transparent; padding: 0; box-shadow: none; }
.topbar > * { position: relative; z-index: 1; }
.topbar::before { display: none; }
.topbar::after { display: none; }
.hero-stage { position: relative; width: min(calc(100% - 56px), 1280px); height: 100%; margin: 0 auto; overflow: hidden; }
.week-plant { display: none; }
.week-plant::before,
.week-plant::after { position: absolute; content: ""; }
.week-plant::before { right: 10px; bottom: 0; width: 4px; height: 34px; border-radius: 999px; background: var(--animal-success-color-active); transform: rotate(12deg); transform-origin: bottom; }
.week-plant::after { right: 14px; bottom: 15px; width: 30px; height: 22px; border-radius: 100% 0 100% 0; background: var(--animal-primary-color); transform: rotate(-18deg); }
.week-plant[data-plant="leaf"]::after { box-shadow: 15px 10px 0 -4px var(--animal-primary-color); }
.week-plant[data-plant="branch"]::before { height: 34px; }
.week-plant[data-plant="branch"]::after { box-shadow: 16px 8px 0 -5px var(--animal-primary-color), -10px 14px 0 -6px var(--animal-primary-color); }
.week-plant[data-plant="tree"]::before { height: 36px; }
.week-plant[data-plant="tree"]::after { width: 29px; height: 24px; border-radius: 50%; box-shadow: 13px 6px 0 -5px var(--animal-primary-color), -10px 7px 0 -5px var(--animal-primary-color); }
.week-plant[data-plant="seed"]::before { height: 18px; }
.week-plant[data-plant="seed"]::after { width: 16px; height: 12px; }
.topbar h1 { position: absolute; top: clamp(58px, 4.2vw, 66px); left: 0; max-width: 4.45em; color: #fff9e6; font-size: clamp(54px, 13vw, 184px); font-weight: 900; line-height: 0.94; text-shadow: 0 7px 0 rgba(61, 84, 55, 0.48), 0 2px 0 rgba(114, 93, 66, 0.34); }
.topbar .caption { position: absolute; top: clamp(178px, 37vw, 430px); left: 0; color: #fff9e6; font-size: clamp(16px, 2.55vw, 34px); text-shadow: 0 3px 0 rgba(61, 84, 55, 0.32); }
.hero-meta::after { display: inline-block; width: 0.62em; color: #fff9e6; content: "|"; animation: type-caret 1s step-end infinite; }
.topbar .body-copy { position: absolute; top: clamp(218px, 42vw, 500px); left: 0; width: min(54vw, 720px); max-width: 22em; color: #7b5633; font-size: clamp(17px, 2.65vw, 36px); font-weight: 700; line-height: 1.38; text-shadow: none; }
.hero-animal { position: absolute; right: 0; top: clamp(84px, 24vw, 304px); display: block; width: clamp(122px, 34vw, 520px); height: clamp(92px, 23vw, 356px); background: url("./assets/animal-island/animal-icon.png") center / contain no-repeat; opacity: 0.94; pointer-events: none; }
.hero-progress { position: absolute; top: clamp(276px, 47vw, 600px); left: 0; display: flex; width: min(44vw, 560px); min-width: 308px; height: clamp(34px, 5vw, 72px); align-items: center; overflow: hidden; border: clamp(2.5px, 0.35vw, 5px) solid #d4c9b4; border-radius: 50px; background: rgb(247, 243, 223); box-shadow: 0 4px 0 0 var(--animal-shadow-input); }
.hero-progress span { position: absolute; inset: 0 auto 0 0; width: 0; min-width: 0; border-radius: inherit; background: repeating-linear-gradient(-45deg, #19c8b9, #19c8b9 10px, #11b7aa 10px, #11b7aa 20px); transition: width var(--animal-motion); }
.hero-progress strong { position: relative; z-index: 1; display: grid; width: clamp(54px, 8vw, 116px); height: calc(100% - 10px); place-items: center; margin-left: auto; margin-right: 5px; border-radius: 50px; background: #fff9e6; color: var(--animal-text-color-body); font-size: clamp(13px, 2vw, 30px); font-weight: 900; box-shadow: 0 2px 0 0 #d4c9b4; }
.section-head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 16px; min-width: 0; margin-bottom: 18px; padding-bottom: 22px; }
.section-head > * { min-width: 0; }
.section-head > .caption { max-width: 46%; overflow-wrap: anywhere; text-align: right; }
.section-head > div > .caption { margin-bottom: 5px; color: #8e755c; font-size: 13px; font-weight: 900; }
.section-head > .caption.strong { display: inline-flex; min-width: 72px; min-height: 40px; align-items: center; justify-content: center; border: 3px solid #d4c9b4; border-radius: 999px; background: #fffdf5; color: var(--animal-primary-color-active); padding: 0 14px; box-shadow: 0 4px 0 0 var(--animal-shadow-btn); font-size: 17px; font-weight: 900; line-height: 1; white-space: nowrap; }
.section-head::after { position: absolute; right: 18px; bottom: 0; left: 18px; height: 12px; background: url("./assets/animal-island/divider-line-brown.svg") center / contain repeat-x; content: ""; opacity: 0.58; }
.week-route { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 7px; }
.route-node { display: grid; min-width: 0; height: 76px; place-items: center; border: 2px solid var(--animal-border-color-light); border-radius: var(--animal-radius-lg); background: rgba(255, 255, 255, 0.62); color: var(--animal-text-color-muted); padding: 8px 4px; transition: all var(--animal-motion); }
.route-node:hover { background: rgba(25, 200, 185, 0.1); color: var(--animal-text-color); transform: translateY(-1px); }
.route-node span { display: grid; width: 30px; height: 30px; place-items: center; border: 2px solid #d4c9b4; border-radius: 50%; background: #fffdf5; color: var(--animal-text-color); box-shadow: 0 2px 0 0 #d4c9b4; font-size: 13px; font-weight: 900; }
.route-node.selected { border-color: var(--animal-focus-yellow); background: var(--animal-focus-yellow); color: var(--animal-text-color-body); box-shadow: 0 4px 0 0 var(--animal-focus-yellow-dark); transform: translateY(-1px); }
.route-node.today span { background: var(--animal-warning-color); color: var(--animal-text-color-body); }
.route-node.done { border-color: var(--animal-primary-color-active); background: var(--animal-primary-color-bg); color: var(--animal-text-color-body); }
.route-node.selected span { background: #fff9e6; color: var(--animal-text-color-body); }
.route-node.done span,
.route-node.appeared span { background: var(--animal-primary-color); color: #fff; }
.route-node.late { border-color: var(--animal-error-color); background: #fff0ea; color: var(--animal-error-color); }
.today-card { background: transparent; padding: 0; box-shadow: none; }
.today-card:hover { transform: none; }
.today-card .section-head { min-height: 118px; margin: 0 0 var(--animal-stack-gap); overflow: hidden; border: 4px solid #d8d0c3; border-radius: var(--animal-radius-surface); background: #fffdf5; padding: 20px 28px 28px; box-shadow: 0 6px 0 0 var(--animal-shadow-btn); }
.today-card .section-head h2 { color: var(--animal-text-color); font-size: 30px; font-weight: 900; line-height: 1.04; }
.today-card .section-head > div > .caption { margin-bottom: 8px; color: #9b8468; font-size: 14px; }
.today-card .section-head::after { right: 38px; left: 38px; opacity: 0.42; }
.exercise-list { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; }
.exercise-item { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 16px; align-items: center; min-height: 154px; border: 3px solid #ebe4d4; border-radius: 28px; background: rgb(247, 243, 223); padding: 24px 28px; color: var(--animal-text-color); box-shadow: 0 4px 10px rgba(107, 92, 67, 0.2); transition: background-color var(--animal-motion), border-color var(--animal-motion), box-shadow var(--animal-motion), transform var(--animal-motion); }
.exercise-item:hover { border-color: var(--animal-primary-color); box-shadow: 0 6px 0 0 rgba(80, 185, 171, 0.28), 0 14px 22px rgba(61, 52, 40, 0.12); transform: translateY(-2px); }
.exercise-item.done { border-color: var(--animal-primary-color-active); background: #e1f8f5; box-shadow: 0 4px 0 0 rgba(80, 185, 171, 0.36), 0 12px 20px rgba(61, 52, 40, 0.08); }
.check-button { position: relative; display: grid; width: 56px; height: 56px; min-width: 56px; min-height: 56px; place-items: center; border-radius: 18px; background: transparent; color: transparent; font-size: 0; line-height: 1; transition: transform var(--animal-motion); }
.check-button::before { position: absolute; width: 40px; height: 40px; border: 3px solid var(--animal-input-border); border-radius: 14px; background: var(--animal-bg-content); box-shadow: inset 0 -2px 0 rgba(122, 91, 52, 0.08); content: ""; transition: all var(--animal-motion); }
.check-button:hover::before { border-color: var(--animal-primary-color); transform: translateY(-1px); }
.check-button:focus-visible::before { outline: 2px solid var(--animal-focus-yellow); outline-offset: 2px; }
.exercise-item.done .check-button::before { border-color: var(--animal-primary-color-active); background: var(--animal-primary-color); box-shadow: 0 3px 0 0 var(--animal-primary-color-active); }
.exercise-item.done .check-button::after { position: absolute; width: 18px; height: 11px; border-bottom: 4px solid #fff; border-left: 4px solid #fff; content: ""; transform: translateY(-3px) rotate(-45deg); }
.check-button:not(:empty) { animation: animal-checkbox-pop var(--animal-motion-fast) cubic-bezier(0.4, 0, 0.2, 1); }
.exercise-copy { display: grid; min-width: 0; gap: 8px; }
.exercise-title-row { display: block; min-width: 0; color: var(--animal-text-color); }
.exercise-name { display: block; min-width: 0; color: var(--animal-text-color); font-size: 27px; font-weight: 900; line-height: 1.08; overflow-wrap: anywhere; }
.exercise-detail { display: inline-block; width: fit-content; max-width: 100%; margin-top: 0; border: 2px solid rgba(212, 201, 180, 0.72); border-radius: 18px; background: rgba(255, 253, 245, 0.82); color: #5f3b1e; padding: 8px 13px 9px; box-shadow: inset 0 -2px 0 rgba(122, 91, 52, 0.08); font-size: 21px; font-weight: 900; line-height: 1.24; overflow-wrap: anywhere; }
.exercise-note { display: block; color: #7f735f; font-size: 16px; font-weight: 800; line-height: 1.35; }
.exercise-item.done .exercise-detail { border-color: rgba(80, 185, 171, 0.42); background: rgba(255, 253, 245, 0.92); }
.small-button,
.complete-button { display: inline-flex; align-items: center; justify-content: center; border: 2px solid transparent; border-radius: var(--animal-radius-pill); font-family: var(--animal-font-family); font-weight: 600; letter-spacing: 0.02em; line-height: 1; transition: all var(--animal-motion); user-select: none; white-space: nowrap; }
.small-button { height: 32px; min-height: 32px; padding: 0 16px; border-color: var(--animal-border-color); border-radius: var(--animal-radius-sm); background: var(--animal-bg-color); color: var(--animal-text-color); font-size: var(--caption); box-shadow: 0 2px 4px 0 rgba(61, 52, 40, 0.06); }
.small-button:hover { border-color: var(--animal-primary-color); color: var(--animal-primary-color); box-shadow: 0 3px 10px 0 rgba(61, 52, 40, 0.1); transform: translateY(-1px); }
.small-button:active { border-color: var(--animal-primary-color-active); color: var(--animal-primary-color-active); transform: translateY(0); }
.small-button.primary { border-color: var(--animal-focus-yellow); background: var(--animal-focus-yellow); color: var(--animal-text-color-body); }
.small-button.danger { border-color: var(--animal-error-color); background: var(--animal-error-color); color: #fff; box-shadow: 0 5px 0 0 var(--animal-error-color-active); }
.small-button.ghost { background: transparent; box-shadow: none; }
.top-copy-button { position: absolute; top: 18px; right: max(18px, calc((100vw - 720px) / 2 + 18px)); z-index: 2; min-width: 64px; max-width: 72px; height: 34px; min-height: 34px; padding: 0 14px; overflow: hidden; border-color: #d4c9b4; border-radius: var(--animal-radius-pill); background: #fffdf5; color: var(--animal-text-color); font-size: 13px; box-shadow: 0 4px 0 0 var(--animal-shadow-btn); }
.complete-strip { position: relative; margin-top: var(--animal-stack-gap-raised); border: 3px solid #fff9e6; border-radius: var(--animal-radius-surface); background: linear-gradient(180deg, #f7cd67 0%, #f5c31c 100%); padding: var(--animal-inset-gap); box-shadow: 0 8px 0 0 #dba90e, 0 16px 22px rgba(61, 52, 40, 0.16); text-align: center; overflow: hidden; }
.complete-strip::before,
.complete-strip::after { position: absolute; border-radius: 50%; background: rgba(255, 253, 245, 0.28); content: ""; pointer-events: none; }
.complete-strip::before { top: -26px; left: 28px; width: 82px; height: 82px; }
.complete-strip::after { right: -24px; bottom: -30px; width: 108px; height: 108px; }
.complete-button { position: relative; z-index: 1; width: 100%; height: 64px; min-height: 64px; padding: 0 32px; border: 4px solid #fffdf5; border-radius: var(--animal-radius-pill); background: #fffdf5; color: var(--animal-text-color); font-size: 24px; font-weight: 900; box-shadow: 0 7px 0 0 var(--animal-shadow-btn); }
.complete-button:hover { border-color: #fffdf5; box-shadow: 0 8px 0 0 var(--animal-shadow-btn); transform: translateY(-1px); }
.complete-button:active { box-shadow: 0 2px 0 0 var(--animal-shadow-btn); transform: translateY(4px); }
.complete-button.done { border-color: #fffdf5; background: var(--animal-success-color); color: #fff; box-shadow: 0 4px 0 0 var(--animal-success-color-active); }
.review-card[hidden] { display: none; }
.review-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.review-stats div { min-height: 72px; border: 2px solid var(--animal-border-color-light); border-radius: var(--animal-radius-base); background: var(--animal-bg-card-title); padding: 10px; }
.review-stats span { display: block; color: var(--animal-text-color); font-size: var(--display); font-weight: 900; line-height: 1; }
.review-stats small { color: var(--animal-text-color-muted); font-size: var(--caption); font-weight: 600; }
.review-table-wrap { position: relative; width: 100%; margin-top: var(--animal-stack-gap); overflow-x: auto; border-radius: var(--animal-radius-card); background: var(--animal-bg-content); padding: 6px; scrollbar-width: none; }
.review-table-wrap::-webkit-scrollbar { display: none; }
.review-table { width: 100%; min-width: 520px; border-collapse: separate; border-spacing: 0; font-family: var(--animal-font-family); }
.review-table thead { background: var(--animal-bg-content); }
.review-table tr { position: relative; transition: background var(--animal-motion), color var(--animal-motion); }
.review-table tr::after { position: absolute; right: 20px; bottom: 0; left: 20px; height: 1px; background: repeating-linear-gradient(90deg, var(--animal-bg-secondary) 0, var(--animal-bg-secondary) 6px, transparent 6px, transparent 12px); content: ""; }
.review-table tr:last-child::after { display: none; }
.review-table th { padding: 14px 20px; color: var(--animal-text-color-body); font-size: 14px; font-weight: 900; letter-spacing: 0.02em; line-height: 1.4; text-align: left; white-space: nowrap; }
.review-table td { padding: 14px 20px; color: var(--animal-text-color-body); font-size: 14px; font-weight: 700; line-height: 1.6; vertical-align: middle; }
.review-table tbody tr.striped { background: rgba(248, 248, 240, 0.6); }
.review-table tbody tr:hover { background-image: repeating-linear-gradient(-45deg, rgba(25, 200, 185, 0.42), rgba(25, 200, 185, 0.42) 10px, rgba(14, 196, 182, 0.42) 10px, rgba(14, 196, 182, 0.42) 20px); background-size: 28.28px 28.28px; clip-path: inset(0 0 0 0 round var(--animal-radius-card)); }
.review-table tbody tr:hover::after { opacity: 0; }
.review-item-cell { width: 76px; color: var(--animal-text-color-muted); white-space: nowrap; }
.review-value-cell { min-width: 150px; color: var(--animal-text-color); font-weight: 900; }
.review-note-cell { color: var(--animal-text-color-muted); }
.review-trail { display: flex; min-width: 0; flex-wrap: wrap; gap: 6px; }
.review-day-chip { display: inline-flex; min-height: 28px; align-items: center; justify-content: center; border: 2px solid var(--animal-border-color-light); border-radius: var(--animal-radius-pill); background: #fffdf5; color: var(--animal-text-color-muted); padding: 0 9px; box-shadow: 0 2px 0 0 #d4c9b4; font-size: 12px; font-weight: 900; line-height: 1; white-space: nowrap; }
.review-day-chip.done { border-color: var(--animal-primary-color-active); background: var(--animal-primary-color); color: #fff; box-shadow: 0 2px 0 0 var(--animal-primary-color-active); }
.review-day-chip.appeared { border-color: var(--animal-primary-color); background: var(--animal-primary-color-bg); color: var(--animal-text-color); }
.review-day-chip.missed { border-color: var(--animal-error-color); background: #fff0ea; color: var(--animal-error-color); }
.archive-card { position: relative; margin-top: var(--animal-stack-gap-raised); overflow: hidden; border: 3px solid #d8d0c3; border-radius: var(--animal-radius-surface); background: linear-gradient(180deg, #fffdf5 0%, var(--animal-bg-content) 100%); padding: 0; box-shadow: 0 6px 0 0 var(--animal-shadow-btn), 0 14px 22px rgba(61, 52, 40, 0.14); }
.archive-card::before { position: absolute; top: -26px; right: 76px; width: 76px; height: 76px; border-radius: 50%; background: rgba(25, 200, 185, 0.11); content: ""; pointer-events: none; }
.archive-card::after { position: absolute; right: 20px; bottom: -34px; width: 118px; height: 78px; border-radius: 50%; background: rgba(111, 186, 44, 0.12); content: ""; pointer-events: none; }
.archive-card:hover { transform: translateY(-2px); }
.archive-card[open] { border-color: var(--animal-primary-color); }
.archive-card summary { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1fr) auto; min-height: 76px; align-items: center; gap: 12px; padding: 12px 18px; cursor: url("./assets/animal-island/cursor-icon-small.png") 4 0, pointer; list-style: none; }
.archive-card summary::-webkit-details-marker { display: none; }
.archive-summary-main { display: flex; min-width: 0; align-items: center; gap: 12px; }
.archive-toggle-icon { position: relative; display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; border-radius: 50%; background: var(--animal-primary-color); color: #fff; box-shadow: 0 3px 0 0 var(--animal-primary-color-active); transition: background-color var(--animal-motion), box-shadow var(--animal-motion), transform var(--animal-motion); }
.archive-toggle-icon::before { color: currentColor; content: "+"; font-size: 22px; font-weight: 900; line-height: 1; transform: translateY(-1px); }
.archive-title-copy { display: grid; min-width: 0; gap: 2px; color: var(--animal-text-color); font-weight: 800; }
.archive-title-copy > span { overflow: hidden; font-size: 22px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
.archive-title-copy small { color: var(--animal-text-color-secondary); font-size: var(--caption); font-weight: 700; letter-spacing: 0.02em; }
.archive-leaf { display: grid; width: 30px; height: 30px; place-items: center; color: var(--animal-primary-color); opacity: 0.55; transition: opacity var(--animal-motion), transform var(--animal-motion); }
.archive-card[open] .archive-toggle-icon { background: var(--animal-primary-color-active); box-shadow: 0 2px 0 0 #3f8f18; transform: rotate(180deg); }
.archive-card[open] .archive-toggle-icon::before { content: "-"; transform: translateY(-2px); }
.archive-card[open] .archive-leaf { opacity: 1; transform: rotate(45deg); }
.archive-panel { position: relative; z-index: 1; padding-bottom: 18px; }
.archive-divider { height: 12px; margin: 0 18px 14px; background: url("./assets/animal-island/divider-line-brown.svg") center / contain repeat-x; opacity: 0.78; }
.calendar-grid { display: flex; gap: 8px; overflow-x: auto; padding: 0 18px; scroll-snap-type: x mandatory; scrollbar-width: none; }
.calendar-grid::-webkit-scrollbar { display: none; }
.calendar-day { display: grid; width: 48px; height: 48px; flex: 0 0 48px; place-items: center; border: 2px solid var(--animal-border-color-light); border-radius: var(--animal-radius-base); background: var(--animal-bg-content); color: var(--animal-text-color-muted); font-size: var(--caption); font-weight: 700; scroll-snap-align: center; transition: all var(--animal-motion); }
.calendar-day.selected { border-color: #0cc0b5; background: #0cc0b5; color: #fff9e3; }
.calendar-day.done { border-color: var(--animal-primary-color-active); background: var(--animal-primary-color-bg); color: var(--animal-text-color-body); }
.calendar-day.late { border-color: var(--animal-error-color); background: #fff0ea; color: var(--animal-error-color); }
.field-label { display: grid; gap: 6px; margin-top: 14px; color: var(--animal-text-color-body); font-size: var(--caption); font-weight: 600; letter-spacing: 0.01em; }
.field-label textarea { width: 100%; min-height: 96px; resize: vertical; border: 2.5px solid var(--animal-input-border); border-radius: var(--animal-radius-lg); background: var(--animal-bg-content); color: var(--animal-text-color-body); padding: 12px 16px; box-shadow: 0 3px 0 0 var(--animal-shadow-input); font: 500 var(--body) / 1.5 var(--animal-font-family); letter-spacing: 0.01em; transition: all var(--animal-motion); }
.field-label textarea:focus { border-color: var(--animal-focus-yellow); box-shadow: 0 3px 0 0 var(--animal-focus-yellow-dark), 0 0 0 3px rgba(255, 204, 0, 0.15); outline: none; }
.danger-link { min-height: 38px; margin-top: 12px; background: transparent; color: var(--animal-error-color); font-size: var(--caption); font-weight: 700; text-decoration: underline; text-underline-offset: 3px; }
.floating-nav { position: fixed; right: 14px; bottom: 14px; left: 14px; z-index: 25; display: grid; grid-template-columns: 58px 74px 58px; justify-content: center; align-items: center; gap: 16px; width: min(calc(100% - 28px), 360px); height: 82px; margin: 0 auto; border: 3px solid #d8d0c3; border-radius: 999px; background: rgba(248, 248, 240, 0.96); padding: 8px 16px 12px; box-shadow: 0 7px 0 0 var(--animal-shadow-btn), 0 14px 22px rgba(61, 52, 40, 0.14); backdrop-filter: blur(14px); }
.floating-nav button { display: grid; width: 58px; height: 52px; min-width: 58px; min-height: 52px; place-items: center; justify-self: center; border: 2px solid #d4c9b4; border-radius: 50px; background: #fffdf5; color: var(--animal-text-color); font-size: 32px; font-weight: 900; line-height: 1; box-shadow: 0 5px 0 0 var(--animal-shadow-btn); transition: all var(--animal-motion); }
.floating-nav button:not(.nav-fab) { position: relative; top: -4px; }
.floating-nav button:hover { border-color: var(--animal-primary-color); background: #fffef8; color: var(--animal-primary-color); box-shadow: 0 6px 0 0 var(--animal-shadow-btn); transform: translateY(-1px); }
.floating-nav button:active { box-shadow: 0 1px 0 0 var(--animal-shadow-btn); transform: translateY(2px); }
.floating-nav .nav-fab { width: 72px; height: 66px; min-width: 72px; min-height: 66px; border: 4px solid #fffdf5; background: var(--animal-focus-yellow); color: var(--animal-text-color-body); font-size: 34px; box-shadow: 0 6px 0 0 var(--animal-focus-yellow-dark), 0 0 0 2px #d4c9b4; }
.floating-nav .nav-fab:hover { border-color: #fffdf5; background: #ffd63a; color: var(--animal-text-color-body); box-shadow: 0 7px 0 0 var(--animal-focus-yellow-dark), 0 0 0 2px var(--animal-primary-color); }
.floating-nav .nav-fab:active { box-shadow: 0 1px 0 0 var(--animal-focus-yellow-dark), 0 0 0 2px #d4c9b4; transform: translateY(3px); }
.toast { position: fixed; right: 18px; bottom: 104px; left: 18px; z-index: 35; display: grid; min-height: 48px; max-width: 460px; place-items: center; margin: 0 auto; border: 2px solid var(--animal-border-color); border-radius: 50px; background: var(--animal-bg-color); color: var(--animal-text-color); padding: 10px 18px; box-shadow: 0 5px 0 0 var(--animal-shadow-btn); font-size: var(--caption); font-weight: 700; opacity: 0; pointer-events: none; transform: translateY(12px); transition: opacity var(--animal-motion), transform var(--animal-motion); }
.toast.show { opacity: 1; transform: translateY(0); }
.modal-backdrop { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; background: rgba(0, 0, 0, 0.35); padding: 20px; animation: animal-fade-in 0.25s ease; }
.modal-backdrop[hidden] { display: none; }
.modal-card { width: min(100%, 520px); max-height: calc(100vh - 64px); clip-path: url("#animal-modal-clip"); border: 0; border-radius: 0; background: var(--animal-bg-content); color: var(--animal-text-color-muted); padding: 48px 48px 32px; box-shadow: none; animation: animal-zoom-in 0.3s ease; overflow: auto; }
.modal-card h2 { color: var(--animal-text-color-body); font-size: 28px; font-weight: 700; }
.complete-modal-card { position: relative; display: grid; justify-items: center; padding: 46px 44px 34px; text-align: center; }
.complete-modal-card h2 { margin-top: 18px; color: var(--animal-text-color); font-size: 32px; font-weight: 900; line-height: 1.1; }
.stamp-mark { display: grid; width: 104px; height: 104px; place-items: center; border: 4px solid var(--animal-primary-color); border-radius: 30px; background: var(--animal-primary-color); color: #fff; font-size: 30px; font-weight: 900; box-shadow: 0 8px 0 0 var(--animal-success-color-active); transform: rotate(-3deg); animation: reward-pop 360ms cubic-bezier(0.2, 1.2, 0.3, 1); }
.reward-button { display: inline-flex; width: min(100%, 360px); height: 64px; min-height: 64px; align-items: center; justify-content: center; border: 2px solid var(--animal-focus-yellow); border-radius: 50px; background: var(--animal-focus-yellow); color: var(--animal-text-color-body); padding: 0 32px; box-shadow: 0 5px 0 0 var(--animal-shadow-btn); font-family: var(--animal-font-family); font-size: 20px; font-weight: 900; letter-spacing: 0.02em; line-height: 1; transition: all var(--animal-motion); user-select: none; white-space: nowrap; }
.reward-button:hover { border-color: var(--animal-focus-yellow); background: #ffd633; box-shadow: 0 6px 0 0 var(--animal-shadow-btn); transform: translateY(-1px); }
.reward-button:active { box-shadow: 0 1px 0 0 var(--animal-shadow-btn); transform: translateY(2px); }
.modal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 16px; }
.modal-actions.single { width: 100%; grid-template-columns: minmax(180px, 360px); justify-content: center; margin-top: 24px; }
.animal-footer-sea { position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; height: 80px; background: url("./assets/animal-island/footer-sea.svg") left bottom / auto 80px repeat-x; opacity: 0.92; pointer-events: none; }
.load-error { border: 1px solid var(--animal-error-color); border-radius: var(--animal-radius-base); background: #fff0ea; padding: 16px; color: var(--animal-error-color); font-weight: 700; }
@keyframes animal-checkbox-pop { 0% { opacity: 0; transform: scale(0.4); }
60% { transform: scale(1.2); }
100% { opacity: 1; transform: scale(1); } }
@keyframes animal-fade-in { from { opacity: 0; }
to { opacity: 1; } }
@keyframes animal-zoom-in { from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); } }
@keyframes reward-pop { 0% { opacity: 0; transform: rotate(-3deg) scale(0.58); }
70% { opacity: 1; transform: rotate(-3deg) scale(1.08); }
100% { opacity: 1; transform: rotate(-3deg) scale(1); } }
@keyframes island-bg-scroll { from { background-position: center 0; }
to { background-position: center 1120px; } }
@keyframes type-caret { 50% { opacity: 0; } }
@keyframes island-loading-bob { 0%,
100% { transform: translateY(0); }
50% { transform: translateY(-8px); } }
@keyframes island-loading-sweep { 0% { transform: translateX(-82%); }
50% { transform: translateX(56%); }
100% { transform: translateX(132%); } }
@media (prefers-reduced-motion: reduce) { *,
*::before,
*::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: 0.01ms !important; } }
@media (min-width: 720px) { .app-shell { padding: 28px 20px 142px; } }
@media (max-width: 520px) { .modal-card { padding: 42px 28px 30px; }
.modal-card h2 { font-size: 24px; } }
@media (max-width: 600px) { :root { --display: 34px; --animal-stack-gap: 12px; --animal-stack-gap-raised: 18px; --animal-surface-pad-x: 16px; --animal-surface-pad-y: 16px; --animal-inset-gap: 12px; --animal-radius-card: 24px; --animal-radius-surface: 28px; }
.app-shell { padding-right: 12px; padding-left: 12px; }
.topbar { margin-bottom: 8px; }
.hero-stage { width: calc(100% - 56px); }
.topbar h1 { max-width: 4.35em; }
.topbar .body-copy { width: min(62vw, 280px); }
.hero-animal { top: 92px; width: min(26vw, 112px); height: 82px; }
.hero-progress { width: calc(100% - 56px); min-width: 0; }
.top-copy-button { right: 18px; min-width: 62px; max-width: 68px; padding: 0 12px; }
.route-card,
.complete-strip,
.review-card { margin-top: var(--animal-stack-gap); padding: var(--animal-surface-pad-y) var(--animal-surface-pad-x); }
.today-card { padding: 0; }
.today-card .section-head { min-height: 104px; padding: 16px 18px 24px; }
.today-card .section-head h2 { font-size: 26px; }
.today-card .section-head > div > .caption { font-size: 13px; }
.section-head > .caption.strong { min-width: 62px; min-height: 36px; padding: 0 11px; font-size: 16px; }
.today-card .section-head::after { right: 24px; left: 24px; }
.review-stats { gap: 6px; }
.review-stats div { min-height: 62px; padding: 8px; }
.review-table { min-width: 480px; }
.review-table th,
.review-table td { padding: 12px 14px; }
.review-value-cell { min-width: 130px; }
.exercise-list { gap: 12px; }
.exercise-item { grid-template-columns: 46px minmax(0, 1fr); gap: 12px; min-height: 136px; border-width: 3px; border-radius: 24px; padding: 20px 18px; }
.check-button { width: 48px; height: 48px; min-width: 48px; min-height: 48px; }
.check-button::before { width: 34px; height: 34px; border-radius: 12px; }
.exercise-item.done .check-button::after { width: 16px; height: 10px; }
.exercise-copy { gap: 7px; }
.exercise-name { font-size: 23px; }
.exercise-detail { border-radius: 16px; padding: 7px 10px 8px; font-size: 18px; }
.exercise-note { font-size: 15px; }
.week-route { gap: 5px; }
.route-node { height: 66px; border-radius: 18px; padding: 6px 2px; }
.route-node span { width: 26px; height: 26px; font-size: 12px; }
.complete-strip { margin-top: var(--animal-stack-gap-raised); padding: var(--animal-inset-gap); }
.complete-button { height: 58px; min-height: 58px; font-size: 21px; } }
fitness-island/
L1 | 独立公共 Skill 仓库
成员清单 SKILL.md: 技能入口,按 Scan/Tidy/Build 三阶段定义 MEMORY.md、fitness-plan.md、plan.json 三个真相源、询问格式、路线收敛、页面生成和安全边界。 README.md: 中文说明,说明定位、三个真相源、触发词、工作流、字体、依赖和目录结构。 references/: 参考模块,保存最小阻力、可持续运动、if-then 计划、重启路线、周复盘取舍原则和 plan.json 页面投影契约。 scripts/: 可执行工具模块,保存项目初始化、fitness-plan.md 播种、plan.json 校验、canonical assets 同构校验、文本收集、字体子集生成和缺字检查脚本。 assets/: 生成资产模块,保存被 verify_assets.py 锁定的 canonical 页面、样式、行为、设计契约、plan.json 示例和页面实际引用的 animal-island 视觉资产,不保存记忆模板或完整字体包。
架构决策 fitness-island 是独立 Skill 仓库,根目录即 Skill 根。它不携带用户个人数据,不携带完整字体源;执行协议集中在 SKILL.md,可持续运动取舍进入 references/minimum-friction.md,锁定页面资产留在 assets/,verify_assets.py 阻断手写空壳,生成项目用 fitness-plan.md 做人类真相源、plan.json 做页面投影,全局共享记忆由协议或 init_project.sh 直接创建,确定性操作留在 scripts/。默认页面使用系统 fallback;Resource Han Rounded CN 只在生产子集存在时启用。
依赖边界 上游依赖用户真实环境、身体限制、训练目标、animal-island-ui 视觉规则、可选 Resource Han Rounded CN 官方源字体和 ~/.fitness-island/MEMORY.md;下游输出带 fitness-plan.md、plan.json、通过 canonical assets 同构校验、统一 Hero 与 Animal Loading 的静态 Fitness Island 项目、可选本地 WOFF2 字体子集和共享记忆更新。
变更日志 2026-06-10: 新增 verify_assets.py,将 index/styles/app/design 与视觉资产从软参考改为逐字锁定交付契约。 2026-05-25: 引入 fitness-plan.md 作为生成项目的人类路线真相源,plan.json 降为页面投影,MEMORY.md 只存稳定事实。 2026-05-25: 默认模板移除不存在字体的 preload/@font-face,改为系统 fallback 先跑通,生产时再启用本地字体子集。 2026-05-25: assets 页面模板同步统一 hero-stage 舞台并增加 Animal Loading 启动遮罩,除 favicon 和顶部标题外与实际项目保持同构。 2026-05-25: README 增加公开 demo 链接和 animal-island-ui UI 资源参考。 2026-05-25: 新增圆角 favicon.svg,网页默认标题统一为「动森训练岛」。 2026-05-25: 从内置技能目录抽离为独立公共 Skill 仓库,根 CLAUDE.md 改为 L1 仓库地图。 2026-05-25: 增加 references/plan-json.md、assets/plan.example.json 和 scripts/validate_plan.py,用硬契约+软建议规定 plan.json。 2026-05-25: 将 styles.css、app.js、design.md 和必要 animal-island 图片收进 assets 作为参考目标;plan.json 保持每次生成。 2026-05-25: 删除 assets/MEMORY.md,改为由 SKILL.md 协议和 init_project.sh 直接创建 ~/.fitness-island/MEMORY.md。 2026-05-25: 将单一记忆改为 ~/.fitness-island/MEMORY.md,方便任意 agent 读取同一份训练默认值;生成项目不再创建第二份 MEMORY.md。 2026-05-25: 新增中文版 README.md,按 trip-map-builder README 结构说明 fitness-island 的定位、共享记忆、工作流、依赖和目录结构。 2026-05-24: 新增 references/minimum-friction.md,补齐最小阻力、持之以恒、if-then 计划、重启路线和周复盘参考资源。 2026-05-24: 按 trip-map-builder/SKILL.md 的章节顺序重写 SKILL.md,固定 Shared memory、三阶段流程、Dependencies、Resources 和 Verification。 2026-05-24: 为 SKILL.md 补齐询问协议、四拍提问格式和路线确认闸门,避免跳过用户归位流程直接生成。 2026-05-24: 按用户给定「这个技能做什么」重写 SKILL.md,并新增共享记忆初始化规则。 2026-05-24: 参照 trip-map-builder 重写 SKILL.md,新增最小共享记忆和 Reset/Route/Build 三阶段结构。 2026-05-24: 奥卡姆瘦身,执行协议压回 SKILL.md,保留 assets/index.html 页面目标和 scripts 确定性工具。 2026-05-24: 新增独立 fitness-island skill 地址,目录和内部 skill id 保持 fitness-island。
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
<!-- [INPUT]: 依赖 SKILL.md 的 Scan/Tidy/Build 流程、fitness-plan.md 中间真相源、references/minimum-friction.md 的最小阻力原则、assets/ canonical 页面资产、公开 demo、animal-island-ui 参考资源和 scripts/ 校验/字体管线 [OUTPUT]: 对外提供 fitness-island 的中文说明、三个真相源、demo 地址、UI 参考、锁定资产契约、安装方式、触发词、工作流、依赖和目录结构 [POS]: fitness-island 的中文入口说明,面向人类读者解释这个 skill 如何生成低压力运动习惯项目 [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md -->
动森训练岛
从身体情况、训练目标、时间窗口和器械照片,到可部署的动森风格运动打卡页。
三阶段流水线:扫描现实约束 → 写清路线 → 生成训练岛页面。
定位:生成每天打开一次的参考习惯系统,不是假装身体会按计划表机械执行的训练脚本。
Demo
查看示例:fitness-obsession-plan.vercel.app
UI 资源参考:guokaigdg/animal-island-ui
共享记忆
只保留一个记忆文件:~/.fitness-island/MEMORY.md。
所有 agent 都优先读写这一个文件。长期有效的训练默认值都写在这里:训练地点、可用器械、时间窗口、最低承诺、重启路线、疼痛/旧伤约束、奖励提示、周复盘结论和项目路径。
它不保存原始身体照片、医疗记录、完整聊天记录、精确住址或其他敏感材料。
三个真相源
~/.fitness-island/MEMORY.md # 下次仍然有用的稳定事实
fitness-plan.md # 人能读懂的训练路线真相
plan.json # 页面渲染用的数据投影fitness-plan.md 写意图,plan.json 管渲染,MEMORY.md 只记长期事实。
这个技能做什么
给 AI agent 一套完整的运动习惯养成工作流:
1. 用户给出身体情况、目标、时间窗口、器械照片或文字描述。 2. Agent 提取硬约束:地点、器械、训练基础、疼痛风险、阻力来源。 3. Agent 主动删掉高摩擦动作,写成 fitness-plan.md。 4. 从 fitness-plan.md 投影出 plan.json。 5. 生成静态页面:动森风格 UI、7 日路线、今日任务、结算按钮、完成盖章、周复盘 Table、可复制 agent 训练数据。 6. 输出可部署项目,手机打开直接用。 7. 更新 ~/.fitness-island/MEMORY.md,只保存下次仍然有用的训练上下文。
安装
npx skills add hiyeshu/fitness-island触发词
说这些话会激活技能:
- “动森训练岛”
- “运动习惯”
- “健身打卡页”
- “训练路线”
- “生成训练岛”
- “做个新手健身计划”
- “把我的器械和时间做成训练路线”
- “fitness island”
- “build a fitness habit page”
工作流
Phase 1:扫描现实约束
从用户给的碎片信息里抽出硬约束,先看现实可做什么,而不是先写理想训练表。
核心原则:
- 只问会改变路线的问题。
- 用户已经给过的信息不重复问。
- 最多问两轮,然后用保守默认值继续。
- 所有需要用户决策的问题使用四拍格式:
Re-ground → Simplify → Recommend → Options。 - 先确认安全边界:尖锐关节痛、麻木、眩晕、胸痛、昏厥感、旧伤复发。
详见 `references/minimum-friction.md`
Phase 2:写清路线
像整理房间一样整理训练计划:先删掉挡住开始的东西,再谈加量。
核心原则:
- 先删高技术动作、复杂器械设置、超时路线和需要意志力解释的环节。
- 训练入口必须能在 2 分钟内开始。
- 新手默认走路、低技术器械、低重量、短路线。
- 路线只保留三件稳定东西:最低承诺、默认路线、重启路线。
- 跳过训练不补课、不惩罚;重启路线是系统维护,不是失败。
fitness-plan.md必须写清:现实约束、删除项、最低承诺、默认路线、重启路线、周复盘和停止规则。
Phase 3:生成训练岛页面
默认生成模式是:先从 assets/ 播种完整页面,再只写路线数据。 不要手写 index.html,不要把 canonical 页面替换成简化空壳。
基于内置页面、CSS、JS 和设计参考生成静态项目,并更新共享记忆:
- 动森训练岛 hero
- 今日进度条
- 7 日路线节点
- 今日任务卡
- 一个黄色结算主按钮
- 完成盖章弹窗
- 周复盘 Table
- 30 天全图 Collapse
- 可复制 agent 训练数据
- 三键底部导航
复制的参考目标:
index.html # 从 assets/index.html 复制
styles.css # 从 assets/styles.css 复制
app.js # 从 assets/app.js 复制
design.md # 从 assets/design.md 复制
assets/favicon.svg
assets/animal-island/锁定资产契约:
index.html、styles.css、app.js、design.md、assets/favicon.svg和assets/animal-island/*必须逐字来自本 skill 的assets/。- 用户路线只写入
fitness-plan.md和plan.json。 - 交付前必须运行
python3 scripts/verify_assets.py /path/to/project。 - 校验失败时重新运行
scripts/init_project.sh /path/to/project,再改数据文件;不要修补生成目录里的 HTML 壳。 - 如果确实要改 UI 组件,先改 skill 仓库里的
assets/design.md、assets/index.html、assets/styles.css、assets/app.js,同步文档后再重新播种项目。
按本次路线生成:
fitness-plan.md # 人类可读的路线真相
plan.json # 从 assets/plan.example.json 播种,再从 fitness-plan.md 投影plan.json 有格式契约,但保持弹性:硬契约只管页面能渲染,软建议才推荐 30 天、每周 7 天、周末复盘。详见 `references/plan-json.md`。
可选增强:
assets/fonts/ # 只有需要自托管圆角中文字体时才生成字体
默认页面用系统 fallback 字体跑通。Resource Han Rounded CN 是可选生产字体源:运行时不加载云字体,不把完整 TTF/OTF/WOFF2 字体包放进 skill。字体子集是生产增强,不是默认输出。
常用命令:
python3 scripts/collect_text.py --root /path/to/project --out /tmp/fitness-text.txt
python3 scripts/validate_plan.py /path/to/project/plan.json
python3 scripts/verify_assets.py /path/to/project
python3 scripts/build_font_subset.py \
--text /tmp/fitness-text.txt \
--medium /path/to/ResourceHanRoundedCN-Medium.ttf \
--heavy /path/to/ResourceHanRoundedCN-Heavy.ttf \
--out /path/to/project/assets/fonts
python3 scripts/check_glyphs.py \
--text /tmp/fitness-text.txt \
--font /path/to/project/assets/fonts/fitness-rounded-cn-medium.woff2依赖
| 工具 | 用途 | 安装 |
|---|---|---|
| Python 3 | 初始化、收集文本、字体脚本 | 系统自带或 brew install python |
| fontTools + brotli | 生成 WOFF2 字体子集和缺字检查 | python3 -m pip install fonttools brotli |
| Resource Han Rounded CN | 圆角中文源字体 | 下载并缓存到本机 |
| 浏览器 | 移动端视觉检查 | Chrome / Codex browser |
目录结构
fitness-island/
├── CLAUDE.md # 技能目录地图,记录职责边界
├── SKILL.md # 技能入口,Scan/Tidy/Build 主流程
├── README.md # 本文件
├── assets/
│ ├── CLAUDE.md # assets 局部地图
│ ├── animal-island/ # 页面实际引用的动森视觉资产
│ ├── app.js # 静态应用行为参考
│ ├── design.md # UI 组件契约参考
│ ├── favicon.svg # 圆角 favicon
│ ├── index.html # Fitness Island 页面骨架
│ ├── plan.example.json # 可运行路线示例
│ └── styles.css # 视觉层参考
├── references/
│ ├── CLAUDE.md # references 局部地图
│ ├── minimum-friction.md # 最小阻力与可持续运动参考
│ └── plan-json.md # plan.json 格式契约
└── scripts/
├── CLAUDE.md # scripts 局部地图
├── init_project.sh # 初始化静态项目
├── validate_plan.py # 检查 plan.json 硬契约
├── verify_assets.py # 检查 canonical assets 同构
├── collect_text.py # 收集页面文本
├── build_font_subset.py # 生成 WOFF2 子集
└── check_glyphs.py # 检查缺字许可
Skill 代码建议使用 MIT。字体遵循 Resource Han Rounded CN 上游授权,生成项目必须保留对应字体 license。
安全边界
这个 skill 只生成训练参考,不做医疗诊断。尖锐关节痛、麻木、刺痛、眩晕、胸痛、昏厥感、动作质量因疼痛改变或旧伤复发时,停止加量,降低路线,必要时找专业人员。
references/
L2 | 父级: ../CLAUDE.md
成员清单 minimum-friction.md: 最小阻力与可持续运动参考,提炼少量开始、渐进增加、环境触发、if-then 计划、复盘减负和安全边界。 plan-json.md: plan.json 页面投影契约参考,声明 fitness-plan.md 是人类真相源,并区分页面必需的硬契约和体验推荐的软建议。
架构决策 references/ 只保存能改变 agent 取舍的稳定参考,不保存完整健身百科。SKILL.md 保持流程骨架;参考文件只在规划路线、处理阻力、定义 plan.json 或复盘调整时读取。
变更日志 2026-05-25: 独立仓库化,父级链接改为相对路径。 2026-05-25: plan-json.md 增加 fitness-plan.md 上游真相边界,避免 plan.json 同时承担人类计划和页面投影。 2026-05-25: 新增 plan-json.md,补齐 plan.json 与 HTML/JS 的对齐契约。 2026-05-24: 新增 minimum-friction.md,为 Fitness Island 补齐持之以恒和最小阻力的参考底座。
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
<!-- [INPUT]: 依赖 fitness-plan.md 的路线真相、assets/app.js 对 plan.json 的实际字段消费、assets/index.html 的路线 DOM 和 Fitness Island 的 7 日路线/30 天全图交互 [OUTPUT]: 对外提供 plan.json 作为页面投影的硬契约、软建议、字段语义和生成注意事项 [POS]: fitness-island references 的数据契约参考,用于保证 plan.json 与 HTML/JS 可灵活对齐 [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md -->
plan.json Contract
fitness-plan.md is the human truth. plan.json is only the page projection. Keep the schema small.
Hard Contract
The page must be able to render from this minimum shape:
{
"weeks": [
{
"theme": "出现",
"signal": "今天来岛上走走。",
"sentence": "只守住入口。",
"plant": "sprout",
"days": [
{
"type": "A 训练",
"title": "背 + 核心",
"phase": "力量入门",
"summary": "低重量,先完成出现。",
"minutes": 10,
"review": false,
"exercises": [
["跑步机", "4.0 km/h,走 5 分钟", "入口"]
]
}
]
}
],
"buffer": {
"theme": "缓冲",
"signal": "不补课,只重启。",
"sentence": "今天到这也算数。",
"plant": "sprout",
"days": []
}
}Required:
- top-level
weeks: non-empty array - each group
days: non-empty array - each day
exercises: non-empty array - each exercise: exactly three strings
[name, detail, note] - optional
buffer.days: appended after all weeks - optional
review: boolean; when true, the weekly review panel appears
Soft Contract
Recommended, not mandatory:
- 30 total days by default: 4 weeks x 7 days + 2 buffer days
- keep each
weeks[].daysat 7 days so the route remains a weekly route - put
review: trueon the last day of each week and final buffer day - keep each day to 1-7 exercises
- keep
minutesrealistic and numeric - use
type + phasefor the small label,titlefor the task section, and
summary for the hero prompt
Field Map
| Field | Used by | Meaning |
|---|---|---|
weeks[].theme | review grouping | week name |
weeks[].signal | route card | short weekly prompt |
weeks[].sentence | hero fallback | fallback sentence |
weeks[].plant | visual state | plant marker key |
days[].type | phase label | day category |
days[].phase | phase label | training stage |
days[].title | task section | day title |
days[].summary | hero prompt | today's route sentence |
days[].minutes | hero meta | planned duration |
days[].review | review panel | show weekly review |
days[].exercises[] | task cards | action tuple |
Rule of taste: do not add fields until HTML or JS actually consumes them.