Files
wangjia 40760aa884
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s
dudu MVP:五端语音输入法初始提交
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调
- desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导)
- android:Compose 主 App + IME(键盘内录音直传)
- ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写)
- design/design-pipeline:设计系统 + token 导出 iOS/Android 主题
- doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:38:37 +08:00

433 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dudu · 需要你做的事</title>
<style>
:root {
--accent: #4F6EF7; --accent-text: #3D58DB; --accent-soft: #EEF1FE;
--bg: #F6F7FA; --card: #FFFFFF;
--text-1: #1B1E26; --text-2: #5A6072; --text-3: #A6ABB8;
--border-1: #E4E6EB; --border-2: #D2D5DD;
--positive: #16A34A; --positive-soft: #DCFCE7;
--warning: #E8890C; --warning-soft: #FCEFD7;
--danger: #DC2626; --danger-soft: #FEE2E2;
--mono: ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 0 80px;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text-1); line-height: 1.7;
}
header {
background: var(--accent); color: #fff; padding: 36px 32px 30px;
}
header h1 { margin: 0 0 4px; font-size: 26px; }
header p { margin: 0; opacity: .85; font-size: 14px; }
.progress-wrap { margin-top: 18px; max-width: 520px; }
.progress-text { font-size: 13px; margin-bottom: 6px; font-family: var(--mono); }
.progress-bar { height: 8px; background: rgba(255,255,255,.25); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: #fff; border-radius: 4px; width: 0; transition: width .3s cubic-bezier(.2,.8,.2,1); }
.wrap { max-width: 880px; margin: 0 auto; padding: 0 24px; }
.group-title {
display: flex; align-items: center; gap: 10px;
font-size: 16px; font-weight: 700; margin: 36px 0 14px; padding: 10px 16px;
border-radius: 8px; border-left: 4px solid;
}
.g-now { background: var(--danger-soft); color: #991B1B; border-color: var(--danger); }
.g-week { background: var(--warning-soft); color: #92400E; border-color: var(--warning); }
.g-later{ background: var(--accent-soft); color: var(--accent-text); border-color: var(--accent); }
.group-title .count { margin-left: auto; font-size: 12px; font-weight: 500; opacity: .75; }
.task {
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
margin-bottom: 14px; overflow: hidden;
}
.task.done { opacity: .55; }
.task-head {
display: flex; align-items: flex-start; gap: 14px;
padding: 16px 20px; cursor: pointer; user-select: none;
}
.task-head:hover { background: #FCFCFD; }
.check {
flex: none; width: 24px; height: 24px; margin-top: 2px;
border: 2px solid var(--border-2); border-radius: 7px; background: var(--card);
display: inline-flex; align-items: center; justify-content: center;
color: transparent; cursor: pointer; transition: all .12s;
}
.task.done .check { background: var(--positive); border-color: var(--positive); color: #fff; }
.task-title { font-size: 16px; font-weight: 600; }
.task.done .task-title { text-decoration: line-through; color: var(--text-2); }
.task-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 5px; font-size: 12px; }
.pill { padding: 1px 10px; border-radius: 999px; font-weight: 600; }
.pill-time { background: var(--accent-soft); color: var(--accent-text); }
.pill-block { background: var(--danger-soft); color: var(--danger); }
.pill-ref { background: #F0F1F4; color: var(--text-2); font-family: var(--mono); font-weight: 500; }
.chevron { margin-left: auto; color: var(--text-3); transition: transform .15s; flex: none; margin-top: 6px; }
.task.open .chevron { transform: rotate(90deg); }
.task-body { display: none; border-top: 1px solid var(--border-1); padding: 18px 22px 20px 58px; }
.task.open .task-body { display: block; }
.why {
font-size: 13.5px; color: var(--text-2); background: var(--bg);
border-left: 3px solid var(--border-2); padding: 8px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;
}
.flow-label { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .08em; margin: 14px 0 8px; }
ol.flow { margin: 0; padding-left: 4px; counter-reset: step; list-style: none; }
ol.flow > li {
counter-increment: step; position: relative; padding: 0 0 14px 38px; font-size: 14px;
}
ol.flow > li::before {
content: counter(step); position: absolute; left: 0; top: 1px;
width: 24px; height: 24px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent-text);
font-size: 12.5px; font-weight: 700; display: flex; align-items: center; justify-content: center;
font-family: var(--mono);
}
ol.flow > li:not(:last-child)::after {
content: ""; position: absolute; left: 11.5px; top: 27px; bottom: 1px; width: 1px; background: var(--border-1);
}
ol.flow .sub { display: block; font-size: 13px; color: var(--text-2); margin-top: 2px; }
code {
background: #F0F1F4; color: var(--accent-text); padding: 1px 7px; border-radius: 4px;
font-family: var(--mono); font-size: 12.5px;
}
.deliver {
margin-top: 14px; padding: 10px 14px; border-radius: 8px;
background: var(--positive-soft); font-size: 13.5px;
}
.deliver b { color: var(--positive); }
.warn-inline { color: var(--danger); font-weight: 600; }
a { color: var(--accent-text); }
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
</style>
</head>
<body>
<header>
<h1>需要你做的事</h1>
<p>dudu 项目 · 只有你能办的事项清单 · 勾选状态保存在本地浏览器 · 更新于 2026-06-11</p>
<div class="progress-wrap">
<div class="progress-text" id="ptext">0 / 0 已完成</div>
<div class="progress-bar"><div class="progress-fill" id="pfill"></div></div>
</div>
</header>
<div class="wrap">
<!-- ════════ 现在就做 ════════ -->
<div class="group-title g-now">🔴 现在就做 — 直接阻塞开发,每项 ≤ 15 分钟<span class="count">3 项</span></div>
<div class="task" data-id="spike-ios">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">iOS spike 真机验证:键盘扩展能否录音</div>
<div class="task-meta">
<span class="pill pill-time">约 5 分钟</span>
<span class="pill pill-block">阻塞 iOS 全线(14D 技术路线)</span>
<span class="pill pill-ref">看板 1A</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:iOS 键盘扩展内能否直接录音决定 iPhone/iPad 的产品体验走"键盘内主路径"还是"deep link 降级方案"。工程我已写好并通过模拟器编译,只缺真机结论。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>终端执行 <code>open ios/spike-kb-mic/DuduKbMicSpike.xcodeproj</code><span class="sub">没有付费开发者账号也行:用免费 Apple ID 个人签名(7 天有效,spike 够用)</span></li>
<li>Xcode 中给 <b>App 和 KeyboardExt 两个 target</b> 分别设置签名<span class="sub">Signing &amp; Capabilities → Team 选你的 Apple IDBundle ID 冲突就在前面加自己的前缀</span></li>
<li>选 App scheme + 你的 iPhone(数据线连接 + 信任电脑),⌘R 安装运行</li>
<li>手机上打开 App → 点「测试 App 内录音(对照组)」→ <b>允许麦克风权限</b><span class="sub">关键一步:键盘扩展自己弹不出权限框,必须靠容器 App 先拿到授权</span></li>
<li>设置 → 通用 → 键盘 → 添加新键盘 → DuduKbMicSpike → 点进去打开<b>「允许完全访问」</b></li>
<li>打开备忘录,长按地球键切到 DuduKbMicSpike 键盘 → 点「测试键盘内录音(1 秒)」</li>
<li>把键盘上显示的结果原文发给 Claude<span class="sub">「✅ 收到 N 帧」= 主路径可行 |「❌ 0 帧 / 报错」= 走降级方案</span></li>
</ol>
<div class="deliver"><b>交付:</b>键盘上显示的结果文字(截图也行)</div>
</div>
</div>
<div class="task" data-id="dashscope-key">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">开通阿里云百炼,拿 DashScope API Key</div>
<div class="task-meta">
<span class="pill pill-time">约 15 分钟</span>
<span class="pill pill-block">阻塞真实语音识别(6B/9A/12B 延迟验收)</span>
<span class="pill pill-ref">看板 6B</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:后端 gummy provider 代码已写好,全链路目前跑的是 mock 假识别。拿到 Key 后改一个环境变量即切真实大模型,桌面端就能说真话出真字。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>登录阿里云 → 搜「百炼」或直达 <code>bailian.console.aliyun.com</code> → 同意开通服务<span class="sub">新用户一般送免费额度;gummy-realtime 按音频时长计费,先确认下价格页</span></li>
<li>控制台右上角「API-KEY 管理」→ 创建 API-KEY<code>sk-</code> 开头)</li>
<li>存入 Bitwarden<code>rbw add dashscope-api-key</code>(密码字段填 Key<span class="sub">按全局规则不放明文文件/环境变量</span></li>
<li>告诉 Claude「key 已就绪,条目名 dashscope-api-key」</li>
</ol>
<div class="deliver"><b>交付:</b>Bitwarden 条目名(不要直接发 Key 内容)</div>
</div>
</div>
<div class="task" data-id="desktop-dev">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">桌面端首次联调:在你的 Mac 上跑起来</div>
<div class="task-meta">
<span class="pill pill-time">约 10 分钟</span>
<span class="pill pill-block">阻塞 12A 全链路联调(无头环境验证不了快捷键/注入)</span>
<span class="pill pill-ref">看板 12A</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:快捷键监听、麦克风采集、文字注入、浮层不抢焦点这四件事只有真实桌面环境能验证。我能改代码、看日志,但按键和说话得你来。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>起后端(终端 1):<code>cd server && docker compose up -d && go run ./cmd/server</code><span class="sub">确认输出 dudu server listening;当前 ASR_PROVIDER=mock,说什么都会出预置句子,属正常</span></li>
<li>起桌面端(终端 2):<code>cd desktop && npm run tauri dev</code><span class="sub">首次会编译 Rust,几分钟;之后秒起</span></li>
<li>系统弹窗依次授予:<b>麦克风权限</b><b>辅助功能权限</b>(系统设置 → 隐私与安全性 → 辅助功能,勾选 dudu/终端)</li>
<li>设置窗口里点「微信登录」走 mock 扫码流程(开发期任意 code 都能登录)</li>
<li>打开备忘录,光标点进正文,<b>按住 ⌘⇧Space 说一句话再松开</b></li>
<li>预期:浮层弹出「聆听中」→ 流式出字 → 松开后文字注入备忘录<span class="sub">任何一步不符合预期,把两个终端的报错输出发给 Claude</span></li>
</ol>
<div class="deliver"><b>交付:</b>「跑通了」或具体卡在哪一步 + 终端报错</div>
</div>
</div>
<!-- ════════ 本周启动 ════════ -->
<div class="group-title g-week">🟠 本周启动 — 外部审核周期长,越早提交越好<span class="count">4 项</span></div>
<div class="task" data-id="domain-icp">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">购买域名 + ICP 备案</div>
<div class="task-meta">
<span class="pill pill-time">操作 1 小时 · 审核 1-3 周</span>
<span class="pill pill-block">微信开放平台回调域名、支付回调、宣传页上线的共同前置</span>
<span class="pill pill-ref">前置于 2A/2B</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:微信「网站应用」的授权回调域名<span class="warn-inline">必须是已备案域名</span>,支付回调和官网同理。备案周期 1-3 周,是整条微信链路的最长前置,必须最先动。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>在阿里云万网查询并购买域名(如 <code>duduime.com</code> / <code>dudu.app</code> 类,注意 .app 等部分后缀不支持备案,<b>选 .com/.cn 稳妥</b></li>
<li>域名实名认证(企业主体,需营业执照)</li>
<li>阿里云 ICP 代备案系统提交:营业执照、法人身份证、域名证书、网站信息(名称写「dudu 语音输入法官网」类)<span class="sub">需要一台阿里云服务器/轻量应用服务器作为备案载体——顺手买一台最低配 ECS,之后部署后端也用它</span></li>
<li>等管局审核(1-3 周),期间会有核验电话/短信</li>
<li>备案号下来后告诉 Claude → 我更新宣传页页脚 + 配置部署</li>
</ol>
<div class="deliver"><b>交付:</b>域名 + 备案号 + ECS 实例信息</div>
</div>
</div>
<div class="task" data-id="wechat-open">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">微信开放平台:账号认证 + 创建两个应用</div>
<div class="task-meta">
<span class="pill pill-time">材料半天 · 审核约 7 个工作日</span>
<span class="pill pill-block">阻塞真实微信登录(桌面扫码 + 移动端一键登录)</span>
<span class="pill pill-ref">看板 2A</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:当前登录走 mock。桌面扫码用「网站应用」,手机 App 用「移动应用」,两个都要建,靠 unionid 把同一用户归一(后端已按此实现)。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>注册 <code>open.weixin.qq.com</code> 账号(企业邮箱)→ 完成<b>开发者资质认证</b>(¥300/年,需营业执照、对公打款验证)</li>
<li>创建<b>网站应用</b>:名称「dudu 语音输入」、官网地址、授权回调域名填备案好的域名<span class="sub">依赖上一项的备案域名;审核约 7 个工作日</span></li>
<li>创建<b>移动应用</b>iOS 填 Bundle ID(建议 <code>app.dudu.ime</code>)、Android 填包名 + 签名 MD5<span class="sub">Android 签名:先让 Claude 生成正式签名 keystore,再取 MD5 填入</span></li>
<li>两个应用审核通过后,把 4 个凭证存入 Bitwarden<code>rbw add wechat-web-appid</code><code>wechat-web-secret</code><code>wechat-mobile-appid</code><code>wechat-mobile-secret</code></li>
<li>告诉 Claude「微信凭证就绪」→ 我替换 mock 实现并联调真实扫码</li>
</ol>
<div class="deliver"><b>交付:</b>Bitwarden 4 个条目名 + unionid 是否已开通的确认</div>
</div>
</div>
<div class="task" data-id="wechat-pay">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">微信支付商户号 + API 证书配置</div>
<div class="task-meta">
<span class="pill pill-time">材料半天 · 审核 1-5 个工作日</span>
<span class="pill pill-block">阻塞真实收款(7D 真实支付联调)</span>
<span class="pill pill-ref">看板 2B</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:订单/入账/对账逻辑已完成并通过测试,差真实商户号验签收款。Native(桌面扫码)和 APP(移动收银台)两个支付产品都要开。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li><code>pay.weixin.qq.com</code> 申请商户号:营业执照、法人身份证、对公账户、经营信息(类目选软件/SaaS 类)</li>
<li>审核通过后做对公账户打款验证、签署协议</li>
<li>产品中心开通 <b>Native 支付</b><b>APP 支付</b> 两个产品</li>
<li>账户中心 → API 安全:设置 <b>APIv3 密钥</b>32 位)、申请并下载<b>商户 API 证书</b>apiclient_key.pem</li>
<li>产品中心 → AppID 账号管理:<b>绑定开放平台的两个 AppID</b>(关键,不绑无法下单)</li>
<li>入 Bitwarden<code>rbw add wxpay-mchid</code><code>wxpay-apiv3-key</code><code>wxpay-cert-serial</code>、私钥 PEM 存 <code>wxpay-private-key</code><span class="sub">回调地址我来配(需要备案域名 + HTTPS)</span></li>
<li>告诉 Claude「商户号就绪」→ 我接 wechatpay-go 真实实现,先 1 分钱真实订单联调</li>
</ol>
<div class="deliver"><b>交付:</b>Bitwarden 4 个条目名 + 已绑定 AppID 的确认</div>
</div>
</div>
<div class="task" data-id="copyright">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">软件著作权申请</div>
<div class="task-meta">
<span class="pill pill-time">材料 1 小时 · 登记 30-60 个工作日</span>
<span class="pill pill-block">阻塞应用商店上架(华为/小米等安卓市场硬性要求)</span>
<span class="pill pill-ref">看板 2C</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:国内安卓应用市场上架要求软著证书,周期最长(30-60 个工作日),不卡开发但卡发布,宜早提交。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>让 Claude 准备材料:源代码文档(前后各 30 页,每页 50 行)+ 软件说明书<span class="sub">代码已有,说一声我就导出整理好</span></li>
<li>中国版权保护中心 <code>ccopyright.com.cn</code> 注册账号(企业主体)</li>
<li>在线填报:软件全称(如「嘟嘟语音输入法软件 V1.0」)、开发完成日期、开发方式(独立开发)等</li>
<li>上传材料 + 邮寄纸质件(按系统提示);官方登记免费,着急可走代理加急(数百到千元)</li>
<li>收到受理通知 → 等证书(电子版即可用于上架)</li>
</ol>
<div class="deliver"><b>交付:</b>受理号;证书下来后存档</div>
</div>
</div>
<!-- ════════ 稍后 ════════ -->
<div class="group-title g-later">⚪ 稍后 — 不阻塞当前开发,到节点再做<span class="count">3 项</span></div>
<div class="task" data-id="apple-dev">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">Apple Developer Program(¥688/年)</div>
<div class="task-meta">
<span class="pill pill-time">申请 1-2 天</span>
<span class="pill pill-ref">iOS 正式开发与上架前</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:spike 用免费签名即可,但正式开发需要它——TestFlight 内测、微信 SDK 的 Universal Link、App Group 正式配置、App Store 上架都依赖。iOS 主线(#14)开工前办好即可。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>决定主体:个人账号(快)或公司账号(需邓白氏编码 D-U-N-S,多 1-2 周)<span class="sub">付费软件 + 微信支付建议公司主体,与微信/商户号主体一致</span></li>
<li><code>developer.apple.com</code> 注册并缴费</li>
<li>开通后告诉 Claude:我来配 App ID<code>app.dudu.ime</code>)、App Group、键盘扩展 entitlements</li>
</ol>
<div class="deliver"><b>交付:</b>账号就绪通知 + Team ID</div>
</div>
</div>
<div class="task" data-id="prod-infra">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">生产环境资源开通(阿里云)</div>
<div class="task-meta">
<span class="pill pill-time">约 1 小时</span>
<span class="pill pill-ref">内测部署前</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:内测前需要一套生产环境。备案时买的 ECS 可先兼任,正式内测建议按下面清单补齐(都选与百炼同地域,降低识别链路延迟)。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>ECS(备案那台可复用,2C4G 起步)+ 绑定域名 + HTTPS 证书(阿里云免费 DV 证书)</li>
<li>RDS PostgreSQL 最低配 + Redis 社区版最低配(或先单机 Docker 跑,量起来再上托管)</li>
<li>OSS Bucket(反馈图片,私有读写)+ 创建子账号 AK 存入 Bitwarden<code>rbw add aliyun-oss-ak</code></li>
<li>告诉 Claude → 我写部署脚本(Docker Compose / systemd+ 上线宣传页和后端</li>
</ol>
<div class="deliver"><b>交付:</b>各资源连接信息(密钥走 Bitwarden)</div>
</div>
</div>
<div class="task" data-id="acceptance">
<div class="task-head">
<span class="check"></span>
<div>
<div class="task-title">验收看板上的「待验收」条目(持续)</div>
<div class="task-meta">
<span class="pill pill-time">随时 · 每条 1 分钟</span>
<span class="pill pill-ref">当前 6 条待验收</span>
</div>
</div>
<span class="chevron"></span>
</div>
<div class="task-body">
<div class="why">为什么:按工作流约定,accepted 状态只有你能标。当前待验收:#3 协议与基建、#4 数据层、#5 认证、#7 计费配额、#8 用户与周边、#15 宣传页。</div>
<div class="flow-label">流程</div>
<ol class="flow">
<li>打开看板 <code>todo/todo.html</code> 浏览「待验收」分组,或直接看交付物(文档/宣传页/测试输出)</li>
<li>认可 → 对 Claude 说 <code>/todo done 3</code>(逐条);或一句话「3 4 5 7 8 15 都验收」</li>
<li>不认可 → <code>/todo reject &lt;id&gt; &lt;原因&gt;</code>,条目自动退回并升为最高优先级</li>
</ol>
<div class="deliver"><b>交付:</b>验收或拒绝的指令</div>
</div>
</div>
</div>
<footer>dudu · 需要你做的事 · 配套看板 <a href="todo.html">todo.html</a></footer>
<script>
// 折叠展开 + 勾选持久化(localStorage
const KEY = 'dudu-forme-v1';
const saved = JSON.parse(localStorage.getItem(KEY) || '{}');
const tasks = [...document.querySelectorAll('.task')];
function refresh() {
const done = tasks.filter((t) => t.classList.contains('done')).length;
document.getElementById('ptext').textContent = `${done} / ${tasks.length} 已完成`;
document.getElementById('pfill').style.width = (done / tasks.length * 100) + '%';
}
tasks.forEach((t) => {
const id = t.dataset.id;
if (saved[id]) t.classList.add('done');
t.querySelector('.task-head').addEventListener('click', (e) => {
if (e.target.closest('.check')) return;
t.classList.toggle('open');
});
t.querySelector('.check').addEventListener('click', () => {
t.classList.toggle('done');
saved[id] = t.classList.contains('done');
localStorage.setItem(KEY, JSON.stringify(saved));
refresh();
});
});
refresh();
// 默认展开第一组未完成项
const firstOpen = tasks.find((t) => !t.classList.contains('done'));
if (firstOpen) firstOpen.classList.add('open');
</script>
</body>
</html>