Single-File HTML Editable Pattern
Overview
This skill captures a repeatable pattern for taking a single-file, offline, localStorage-backed
HTML app (vanilla JS, no build, no server, no CDN) and adding two capabilities that are tedious
to get right by hand:
- Record-level editing — every stored record gets an "编辑" button that backfills the input
form, switches the primary button to "保存修改", offers a "取消", and saves in place byid
instead of creating a duplicate. - Grouped longitudinal comparison for text-only types — for record types that are free-text
(no numeric sub-fields, hence no trend chart), group records by a chosen field (e.g. 检查部位 /
exam site) with a filter dropdown, so repeated exams of the same site line up chronologically.
It also mandates a strict jsdom verification step (real click/change events, dates generated
dynamically so tests are stable on any run date, simulated-reload persistence checks) and a
byte-identical skill-sync discipline so a shipped skill copy is provably the same file that was
verified. Use it when building a new such app or augmenting an existing one.
When to use
- A request to make entries in a standalone HTML tracker editable ("让记录能改", "加编辑功能", "记录可编辑").
- A request to compare free-text records over time ("文字结果比对不方便", "按部位看变化", "纵向比对").
- A request about a 个人健康档案 / 健康档案 / 健康记录 style app — either generate a new one from
the bundled template (assets/health-archive/index.html, verified) or augment an existing
single-file app with editing/text grouping. e.g. "做一个个人健康档案台", "给个人健康档案加编辑功能",
"健康档案里的 B超结果按部位比对". - Distributing a single-file HTML template as a WorkBuddy skill and keeping its bundled copy
verifiably identical to the tested work copy.
Bundled example: 个人健康档案台
assets/health-archive/ ships the full worked example that this pattern was distilled from:
a single-file, offline 个人健康档案台 (personal health archive dashboard) with 7 modules
(体检指标趋势 / 用药·就诊 / 每日体征打卡 / 报告附件归档 / 复查提醒 / 家庭病史 / 过敏史),
all records editable in place, and text-only imaging results (B超/核磁/CT) grouped by 检查部位
with a filter dropdown. Its verify.cjs runs 194 jsdom checks and is the reference
implementation of every step in this skill. Use it as a starting point or to study the pattern
applied end-to-end.
Do NOT use this for apps with a backend/API, a multi-file build system, or a framework that already
provides CRUD — the pattern targets the no-dependency, vanilla-JS, localStorage case.
Core pattern: edit state
Apply these steps per module that stores an array of records under a key in the app's DB
object (e.g. DB.meds, DB.metrics). The reference file
references/edit-state-pattern.md holds the full copy-pasteable code.
1. Global edit state + per-module cancel button
let editing = null; // null = "add" mode; {id, kind} = currently editing
In each module's input card, add a hidden "取消" button next to the primary add button:
<div class="btn-col"><button class="primary" id="d-add">添加</button>
<button id="d-cancel" style="display:none">取消</button></div>
2. Toggle add/edit UI appearance
Keep a map kind -> [addBtnId, cancelBtnId]. setEditMode(kind, on) flips the add button's
label to "保存修改" (caching the original in dataset.add) and shows/hides the cancel button.
cancelEdit() resets editing, flips all modules back to add mode, and clears the forms.
3. Add an edit button column to the list renderer
If using a shared renderRows(container, rows, cols, onDel, onEdit) helper, render an extra
<button data-edit="id">编辑</button> column when onEdit is supplied and call onEdit(r) on
click. For custom renderers (charts, cards), add the same data-edit button inside each row and
wire its click to startEditX(r.id).
4. Backfill the form on edit start
startEditX(id) finds the record, sets editing = {id, kind:"X"}, assigns each form field from
the record, then setEditMode("X", true). For modules with a type selector that rebuilds fields
(such as the metric picker), switch the selector to the record's type before backfilling the
type-specific fields.
5. Save handler updates in place
const e = (editing && editing.kind==="X") ? DB.X.find(r=>r.id===editing.id) : null;
if(e){ Object.assign(e, { /* new field values */ }); } // id preserved
else { DB.X.push({ id:uid(), /* new record */ }); }
editing = null; setEditMode("X", false); /* reset form */
save(); renderAll();
The invariant that separates "edit" from "add": when editing is set, the handler mutates the
existing record by id (array length unchanged) instead of pushing a new one.
Module-specific quirks (record these when they appear)
- Date-unique records (daily check-ins): on edit save, if the new date collides with another
record's date, reject with atoastand stay in edit mode (setEditMode(kind,true)); never
overwrite the other record. - File-backed records (report attachments): the file is a base64 blob on the record. Editing
changes metadata (name/date); provide a separate "重新选择文件" that replacesdata/mime/size.
FileReader.readAsDataURLis async — test it with a shortawait. - Records with history (reminders): editing must preserve
done/history; only update visible
fields, never reset completion state.
Core pattern: grouped text comparison
For a record type flagged cfg.textOnly (free text, no chart), replace the flat reverse-chron list
with grouping by a field:
if(cfg.textOnly){
const groups = {};
rows.forEach(r=>{ const site=(r.vals&&r.vals.site)||"未注明部位"; (groups[site]=groups[site]||[]).push(r); });
const sites = Object.keys(groups).sort();
let html = '<select id="textFilter"><option value="全部" selected>全部</option>' +
sites.map(s=>'<option value="'+esc(s)+'">'+esc(s)+' ('+groups[s].length+')</option>').join("") + '</select>';
const shown = textFilter==="全部" ? sites : [textFilter];
shown.forEach(s=>{
const g=groups[s], dates=g.map(r=>r.date).sort();
const range = dates.length>1 ? dates[0]+" ~ "+dates[dates.length-1] : dates[0];
html += '<div class="grp"><div class="grp-h">'+esc(s)+' <span class="tag">'+g.length+' 条</span>'+
' <span class="hint">'+esc(range)+'</span></div>' + recordCards(g, cfg) + '</div>';
});
el.innerHTML = html;
const tf = document.getElementById("textFilter");
if(tf) tf.addEventListener("change", ()=>{ textFilter=tf.value; renderMetricChart(); });
return;
}
Rules:
- Keep a module-scoped let textFilter="全部"; and reset it to "全部" whenever the record-type
selector changes (so switching to a different type starts unfiltered).
- Group headers show the field value + count + date range — that is what makes "same site, over
time" readable at a glance.
- The card renderer (recordCards) reverses internally to newest-first; pass the group in natural
order and do not double-reverse.
Strict verification (jsdom)
Verification must be real, not asserted-by-faith. Use the bundled scripts/verify_harness.cjs as
the starting harness. It provides, out of the box:
- A jsdom loader that executes the page's inline scripts (
runScripts:"dangerously",
pretendToBeVisual:true,urlset solocalStorageworks). - Event helpers
ev()(click) andchg()(change). read()to parse the app'slocalStorageJSON (set the key constant).- Dynamic date helpers stable on any run date:
T0,shiftDays(s,n),lastDayOfMonth(s), and
addMonths(s,n)with month-end clamping (e.g.08-31 + 1mo = 09-30, computed inDate.UTC
to avoid timezone off-by-one). clickEditIn(listId, id)to locate and click a record's edit button.setFile(content, name, mime)to inject aFileinto<input type=file>and firechangefor
file-replacement testing (await a short timeout for the asyncFileReader).byteIdentical(a, b)for the sync check.- A
jsErrorscollector and a universal "no JS error on load" assertion.
For every edit feature assert at minimum: (a) clicking 编辑 backfills the form and flips the button
to "保存修改"; (b) saving updates the existing record by id — array length unchanged, id
unchanged, the changed field updated; (c) clicking 取消 leaves data untouched; (d) for date-unique
types, a colliding date is rejected and edit mode persists; (e) for file types, replacing the file
updates data/mime/size; (f) after a simulated reload (re-load the DOM from saved
localStorage), the edit result and the grouping still render.
Skill-sync discipline (when shipping as a skill)
- Keep the work copy (e.g.
health-archive/index.html+_verify.cjs) as the only place you
edit and verify. - After
verify.cjsis green,cpboth files into the skill directory and rundiff -qto
confirm byte-identical. - Run
verify.cjsinside the skill directory for end-to-end acceptance — this proves the shipped
copy passes, not just the work copy. - Update the skill's
SKILL.md: module list, data-model buckets, behavior notes (edit + grouping),
and the verification count.
Resources
scripts/verify_harness.cjs— reusable jsdom test harness with all helpers above; copy it next to
the app'sindex.html, set thelocalStoragekey and any app-specific element ids, then add
per-app assertions in the marked block.references/edit-state-pattern.md— full copy-pasteable code for the edit-state skeleton, the
grouped-text renderer, the date utilities, and the exact module quirks, with the surrounding
renderRows/renderMetricChartcontext.
Installation(安装 / 发给别人后怎么装)
这个 skill 本质上是一个目录,分发出去后放到本机的 skill 目录,WorkBuddy 就能直接唤起。
下面两种装法效果完全一致,选顺手的即可。
方式 A:从 zip 解压(推荐给接收方)
- 拿到
single-file-html-editable.zip。 - 解压到 WorkBuddy 的 skills 目录(不要多套一层文件夹,解压后应是
single-file-html-editable/SKILL.md):
- Windows:把 zip 内容解压进%USERPROFILE%\.workbuddy\skills\
- macOS / Linux:解压进~/.workbuddy/skills/ - 重启 WorkBuddy(或刷新技能列表),用「个人健康档案」「给单文件 HTML 加编辑」「健康档案里的 B超按部位比对」等词即可唤起。
方式 B:直接拷目录
把 single-file-html-editable/ 整个文件夹(含 SKILL.md、references/、scripts/、assets/)复制到上面的 skills/ 目录即可,效果与方式 A 相同。
验证装上了
- 唤起测试:用一句话试一下,例如「做一个个人健康档案台」「给单文件 HTML 加编辑功能」。
- 或直接检查技能目录下是否出现
single-file-html-editable/SKILL.md。 - 想跑自带验收:进入
skills/single-file-html-editable/assets/health-archive/,执行
node verify.cjs(需先npm i jsdom到该目录或全局),看到194/194 PASS即模板完好。