feat: launch LLM Atlas research course

This commit is contained in:
wuyang
2026-07-28 21:55:19 +08:00
commit 7d5c5e4c54
33 changed files with 13285 additions and 0 deletions
+412
View File
@@ -0,0 +1,412 @@
---
const axes = [
{
id: "token",
label: "序列 / Token",
title: "KDA × Gated MLA:让一百万 Token 既高效流动,也保留全局精确交互",
plain: "三层 KDA 像持续更新的“工作记忆”,每四层插入一层全局 MLA,像定期把整本笔记摊开重看。",
points: ["3 个 KDA 层 + 1 个 Gated MLA 层为一组", "主干共 69 层 KDA、24 层 Gated MLA", "KDA 线性扩展;MLA 周期性补足全局两两交互"],
source: "K3 §2.1",
},
{
id: "depth",
label: "深度 / Layer",
title: "Attention Residuals:每一层不只接住上一步,而是有选择地回看更早层",
plain: "普通残差像接力棒,只能拿到累加后的结果;AttnRes 更像档案索引,可以挑选哪一层的中间表示最有用。",
points: ["学习 pseudo-query,计算跨层注意力权重", "覆盖 embedding、当前 block 与先前 block", "目标是改善 93 层网络中的信息与梯度流"],
source: "K3 §2.2",
},
{
id: "channel",
label: "宽度 / Expert",
title: "Stable LatentMoE896 位专家里,每个 Token 只请 16 位",
plain: "模型把“知识容量”和“本次计算量”拆开:专家库很大,但每次只激活最匹配的一小组。",
points: ["2.8T 总参数,约 104B 激活参数", "896 个 routed experts,另有 2 个 shared experts", "Normalized LatentMoE、SiTU-GLU、Quantile Balancing 稳住极稀疏路由"],
source: "K3 §2.3",
},
{
id: "vision",
label: "视觉 / Input",
title: "MoonViT-V2:图像不是外部 OCR 结果,而是进入同一主干的视觉 Token",
plain: "视觉编码器先把图像压成一串向量,再由轻量投影器把它们放进和文字相同的表示空间。",
points: ["401M 参数视觉编码器", "训练中联合文本、图像与视频数据", "支持视觉反馈闭环:看截图、改代码、再次验证"],
source: "K3 §2.4",
},
{
id: "system",
label: "系统 / Scale",
title: "算法—系统协同:模型结构必须能在真实集群上被训练、强化学习和服务",
plain: "一个公式只有在 GPU 内核、跨卡通信、显存和调度上都跑得通,才真正成为 2.8T 模型的一部分。",
points: ["FlashKDA 与 KDA Context Parallelism", "MoonEP 的平衡专家并行与零拷贝通信", "长上下文 RL 的外置 KV Cache、可恢复 microVM 沙箱与部分 rollout"],
source: "K3 §5",
},
];
---
<figure class="architecture-explorer" data-architecture>
<div class="architecture-stage" aria-label="Kimi K3 简化架构图">
<svg viewBox="0 0 920 510" role="img" aria-labelledby="k3-arch-title k3-arch-desc">
<title id="k3-arch-title">Kimi K3 三维信息流简化图</title>
<desc id="k3-arch-desc">文字与图像进入模型后,依次通过由 KDA、Gated MLA 和 Stable LatentMoE 组成的模块;Attention Residuals 连接不同深度的表示。</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor"></path>
</marker>
</defs>
<g class="arch-flow">
<path d="M82 254H170M310 254H367M522 254H579M734 254H832" />
<path class="depth-link" d="M230 207C310 68 633 62 669 205" />
</g>
<g class="arch-node input-text" data-axis="token">
<rect x="20" y="215" width="62" height="78" rx="8" />
<text x="51" y="246">文</text>
<text x="51" y="269">字</text>
</g>
<g class="arch-node input-vision" data-axis="vision">
<rect x="20" y="323" width="62" height="78" rx="8" />
<circle cx="42" cy="346" r="7" />
<path d="M30 386l16-20 11 11 15-18v27z" />
<text x="51" y="421">MoonViT</text>
</g>
<g class="arch-node embed" data-axis="vision token">
<rect x="170" y="204" width="140" height="100" rx="8" />
<text x="240" y="244">Embedding</text>
<text class="sub" x="240" y="269">共享表示空间</text>
<path d="M82 362H128V278H170" />
</g>
<g class="arch-node attention kda" data-axis="token system">
<rect x="367" y="191" width="155" height="126" rx="8" />
<text x="444" y="233">KDA</text>
<text class="sub" x="444" y="258">线性工作记忆</text>
<text class="tag" x="444" y="292">× 3</text>
</g>
<g class="arch-node attention mla" data-axis="token">
<rect x="579" y="191" width="155" height="126" rx="8" />
<text x="656" y="233">Gated MLA</text>
<text class="sub" x="656" y="258">压缩的全局注意力</text>
<text class="tag" x="656" y="292">× 1</text>
</g>
<g class="arch-node output" data-axis="token">
<rect x="832" y="215" width="68" height="78" rx="8" />
<text x="866" y="248">下一个</text>
<text x="866" y="270">Token</text>
</g>
<g class="arch-node moe moe-a" data-axis="channel system">
<rect x="367" y="350" width="155" height="92" rx="8" />
<text x="444" y="385">Stable LatentMoE</text>
<text class="sub" x="444" y="410">896 → 16 experts</text>
<path d="M444 317V350" />
</g>
<g class="arch-node moe moe-b" data-axis="channel system">
<rect x="579" y="350" width="155" height="92" rx="8" />
<text x="656" y="385">Stable LatentMoE</text>
<text class="sub" x="656" y="410">896 → 16 experts</text>
<path d="M656 317V350" />
</g>
<g class="attn-res-label" data-axis="depth">
<rect x="389" y="55" width="309" height="54" rx="27" />
<text x="544" y="88">Attention Residuals · 跨层选择</text>
</g>
<g class="system-base" data-axis="system">
<rect x="170" y="470" width="564" height="24" rx="12" />
<text x="452" y="486">FlashKDA · MoonEP · 1M RL · Prefix Cache · Fleet Scheduling</text>
</g>
</svg>
<p class="figure-caption"><b>图 01</b> Kimi K3 架构的教学化简图。它强调 token、depth、channel 三个信息流维度,不代表逐算子实现;依据 K3 Technical Report Figure 2 与 §2 重绘。</p>
</div>
<div class="architecture-controls" role="tablist" aria-label="选择信息流维度">
{axes.map((axis, index) => (
<button
type="button"
role="tab"
id={`axis-${axis.id}`}
aria-controls={`axis-panel-${axis.id}`}
aria-selected={index === 0 ? "true" : "false"}
data-axis-button={axis.id}
>
<span>{String(index + 1).padStart(2, "0")}</span>
{axis.label}
</button>
))}
</div>
<div class="architecture-details">
{axes.map((axis, index) => (
<section
id={`axis-panel-${axis.id}`}
role="tabpanel"
aria-labelledby={`axis-${axis.id}`}
hidden={index !== 0}
data-axis-panel={axis.id}
>
<span class="micro-label">{axis.source}</span>
<h3>{axis.title}</h3>
<div class="plain-language">
<b>先用直觉说</b>
<p>{axis.plain}</p>
</div>
<ul>
{axis.points.map((point) => <li>{point}</li>)}
</ul>
</section>
))}
</div>
</figure>
<script>
document.querySelectorAll<HTMLElement>("[data-architecture]").forEach((root) => {
const buttons = [...root.querySelectorAll<HTMLButtonElement>("[data-axis-button]")];
const panels = [...root.querySelectorAll<HTMLElement>("[data-axis-panel]")];
const nodes = [...root.querySelectorAll<SVGGElement>("[data-axis]")];
const select = (axis: string) => {
buttons.forEach((button) => {
const active = button.dataset.axisButton === axis;
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.axisPanel !== axis;
});
nodes.forEach((node) => {
const active = node.dataset.axis?.split(" ").includes(axis);
node.toggleAttribute("data-highlight", Boolean(active));
});
};
buttons.forEach((button, index) => {
button.addEventListener("click", () => select(button.dataset.axisButton ?? "token"));
button.addEventListener("keydown", (event) => {
if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
event.preventDefault();
const delta = event.key === "ArrowRight" ? 1 : -1;
const next = buttons[(index + delta + buttons.length) % buttons.length];
next.focus();
select(next.dataset.axisButton ?? "token");
});
});
select("token");
});
</script>
<style>
.architecture-explorer {
max-width: 980px;
margin: 38px 0;
border: 1px solid var(--line);
background: var(--paper-raised);
}
.architecture-stage {
padding: 30px 30px 18px;
border-bottom: 1px solid var(--line);
}
svg {
width: 100%;
height: auto;
overflow: visible;
color: var(--muted-light);
}
.arch-flow path {
fill: none;
stroke: currentColor;
stroke-width: 1.5;
marker-end: url(#arrow);
}
.arch-flow .depth-link {
stroke-dasharray: 6 6;
}
.arch-node rect,
.attn-res-label rect,
.system-base rect {
fill: var(--paper);
stroke: var(--line-strong);
stroke-width: 1.2;
transition: fill 160ms ease, stroke 160ms ease, opacity 160ms ease;
}
.arch-node text,
.attn-res-label text,
.system-base text {
fill: var(--ink);
font-family: var(--display);
font-size: 15px;
font-weight: 650;
text-anchor: middle;
}
.arch-node .sub {
fill: var(--muted);
font-size: 11px;
font-weight: 450;
}
.arch-node .tag {
fill: var(--copper);
font-family: var(--mono);
font-size: 11px;
}
.arch-node path {
fill: none;
stroke: var(--line-strong);
stroke-width: 1.4;
}
.input-vision circle {
fill: var(--copper);
}
.input-vision > path:first-of-type {
fill: var(--sky-pale);
stroke: var(--sky);
}
.input-vision text {
fill: var(--muted);
font-family: var(--mono);
font-size: 8px;
}
[data-axis] {
opacity: 0.42;
transition: opacity 160ms ease;
}
[data-axis][data-highlight] {
opacity: 1;
}
[data-highlight].attention rect {
fill: var(--sky-pale);
stroke: var(--sky);
}
[data-highlight].moe rect {
fill: var(--copper-pale);
stroke: var(--copper);
}
[data-highlight].attn-res-label rect {
fill: var(--violet-pale);
stroke: var(--violet);
}
[data-highlight].input-vision rect {
fill: var(--sage-pale);
stroke: var(--sage);
}
[data-highlight].system-base rect {
fill: var(--sage-pale);
stroke: var(--sage);
}
.attn-res-label text,
.system-base text {
font-size: 11px;
}
.figure-caption {
margin-top: 12px;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.65;
}
.architecture-controls {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-bottom: 1px solid var(--line);
}
.architecture-controls button {
display: grid;
gap: 8px;
min-height: 72px;
padding: 13px;
border: 0;
border-right: 1px solid var(--line);
background: transparent;
color: var(--muted);
text-align: left;
cursor: pointer;
}
.architecture-controls button:last-child {
border-right: 0;
}
.architecture-controls button span {
color: var(--muted-light);
font: 0.61rem/1 var(--mono);
}
.architecture-controls button[aria-selected="true"] {
background: var(--ink);
color: var(--paper-raised);
}
.architecture-controls button[aria-selected="true"] span {
color: var(--copper-pale);
}
.architecture-details {
min-height: 355px;
padding: 32px;
}
.architecture-details h3 {
max-width: 800px;
margin: 15px 0 22px;
font-size: clamp(1.4rem, 2.5vw, 2rem);
}
.architecture-details ul {
display: grid;
gap: 9px;
margin: 22px 0 0;
padding-left: 1.2rem;
color: var(--muted);
}
@media (max-width: 720px) {
.architecture-stage {
padding: 16px 10px;
overflow-x: auto;
}
svg {
min-width: 720px;
}
.architecture-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.architecture-controls button {
border-bottom: 1px solid var(--line);
}
.architecture-details {
min-height: 440px;
padding: 23px;
}
}
</style>
+227
View File
@@ -0,0 +1,227 @@
---
const tokens = ["小猫", "坐在", "柔软的", "垫子", "上"];
const weights = [
[0.44, 0.14, 0.08, 0.26, 0.08],
[0.31, 0.24, 0.08, 0.25, 0.12],
[0.07, 0.08, 0.26, 0.51, 0.08],
[0.25, 0.09, 0.32, 0.27, 0.07],
[0.07, 0.15, 0.05, 0.55, 0.18],
];
---
<figure class="attention-lab" data-attention-lab data-weights={JSON.stringify(weights)}>
<div class="lab-header">
<div>
<span class="micro-label">INTERACTIVE / SELF-ATTENTION</span>
<h3>点一个词,看它“回头看”谁</h3>
</div>
<p>示意权重不是训练模型的真实输出;它只帮助理解一次注意力查询的流程。</p>
</div>
<div class="token-row" role="tablist" aria-label="选择作为 Query 的词">
{tokens.map((token, index) => (
<button
type="button"
role="tab"
aria-selected={index === 4 ? "true" : "false"}
data-query={index}
>
<span>{index + 1}</span>{token}
</button>
))}
</div>
<div class="attention-result">
<div class="query-card">
<span>QUERY / 我正在理解</span>
<strong data-query-label>上</strong>
<p>Query 是当前词提出的问题:“为了更新我的表示,我应该从哪些词取信息?”</p>
</div>
<div class="weight-list" aria-live="polite">
{tokens.map((token, index) => (
<div class="weight-item">
<span>{token}</span>
<div><i data-bar={index} style={`width:${weights[4][index] * 100}%`}></i></div>
<b data-value={index}>{Math.round(weights[4][index] * 100)}%</b>
</div>
))}
</div>
</div>
<figcaption><b>图 02</b> 单个注意力头的教学示意。真正的模型会在每一层、每个头上并行进行类似计算,权重由 QKᵀ 经缩放和 softmax 得到。</figcaption>
</figure>
<script>
document.querySelectorAll<HTMLElement>("[data-attention-lab]").forEach((root) => {
const matrix = JSON.parse(root.dataset.weights ?? "[]") as number[][];
const labels = ["小猫", "坐在", "柔软的", "垫子", "上"];
const buttons = [...root.querySelectorAll<HTMLButtonElement>("[data-query]")];
const queryLabel = root.querySelector<HTMLElement>("[data-query-label]");
const select = (index: number) => {
buttons.forEach((button, buttonIndex) => {
button.setAttribute("aria-selected", String(buttonIndex === index));
});
if (queryLabel) queryLabel.textContent = labels[index];
matrix[index]?.forEach((weight, targetIndex) => {
const bar = root.querySelector<HTMLElement>(`[data-bar="${targetIndex}"]`);
const value = root.querySelector<HTMLElement>(`[data-value="${targetIndex}"]`);
if (bar) bar.style.width = `${weight * 100}%`;
if (value) value.textContent = `${Math.round(weight * 100)}%`;
});
};
buttons.forEach((button) => {
button.addEventListener("click", () => select(Number(button.dataset.query ?? 0)));
});
});
</script>
<style>
.attention-lab {
max-width: 900px;
margin: 38px 0;
border: 1px solid var(--line);
background: var(--paper-raised);
}
.lab-header {
display: grid;
grid-template-columns: 1fr 270px;
gap: 34px;
align-items: end;
padding: 30px;
border-bottom: 1px solid var(--line);
}
.lab-header h3 {
margin: 12px 0 0;
font-size: 1.65rem;
}
.lab-header p {
color: var(--muted);
font-size: 0.74rem;
line-height: 1.7;
}
.token-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-bottom: 1px solid var(--line);
}
.token-row button {
display: grid;
gap: 9px;
min-height: 78px;
padding: 14px;
border: 0;
border-right: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.token-row button:last-child {
border-right: 0;
}
.token-row button span {
color: var(--muted-light);
font: 0.62rem/1 var(--mono);
}
.token-row button[aria-selected="true"] {
background: var(--ink);
color: var(--paper-raised);
}
.attention-result {
display: grid;
grid-template-columns: 240px 1fr;
}
.query-card {
padding: 30px;
background: var(--paper-deep);
}
.query-card span {
color: var(--muted);
font: 0.62rem/1.5 var(--mono);
}
.query-card strong {
display: block;
margin: 22px 0;
color: var(--copper);
font-size: 2.6rem;
}
.query-card p {
color: var(--muted);
font-size: 0.75rem;
line-height: 1.75;
}
.weight-list {
display: grid;
gap: 15px;
padding: 30px;
}
.weight-item {
display: grid;
grid-template-columns: 70px 1fr 42px;
gap: 14px;
align-items: center;
}
.weight-item > span,
.weight-item > b {
font-size: 0.75rem;
}
.weight-item > b {
color: var(--muted);
font-family: var(--mono);
text-align: right;
}
.weight-item div {
height: 10px;
overflow: hidden;
background: var(--paper-deep);
}
.weight-item i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--sky), var(--copper));
transition: width 280ms ease;
}
figcaption {
padding: 14px 30px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.72rem;
line-height: 1.65;
}
@media (max-width: 720px) {
.lab-header,
.attention-result {
grid-template-columns: 1fr;
}
.token-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.token-row button {
border-bottom: 1px solid var(--line);
}
}
</style>
+152
View File
@@ -0,0 +1,152 @@
---
const milestones = [
{
year: "2024.01",
model: "DeepSeek LLM",
idea: "公开尺度规律与中英双语预训练,建立 7B / 67B dense 基线。",
bridge: "先弄清规模、数据与训练配方,再做稀疏化。",
url: "https://arxiv.org/abs/2401.02954",
},
{
year: "2024.01",
model: "DeepSeekMoE",
idea: "细粒度专家分割 + shared experts,让专家更专、公共知识不必重复。",
bridge: "把容量扩张和每 Token 计算量分开。",
url: "https://arxiv.org/abs/2401.06066",
},
{
year: "2024.05",
model: "DeepSeek-V2",
idea: "MLA 压缩 KV CacheDeepSeekMoE 扩张稀疏容量。",
bridge: "训练经济性之外,开始直接优化推理内存与吞吐。",
url: "https://arxiv.org/abs/2405.04434",
},
{
year: "2024.12",
model: "DeepSeek-V3",
idea: "671B-A37B、FP8 训练、无辅助损失负载均衡、MTP 与 DualPipe。",
bridge: "模型算法、数值格式与集群通信共同设计。",
url: "https://arxiv.org/abs/2412.19437",
},
{
year: "2025.01",
model: "DeepSeek-R1",
idea: "R1-Zero 展示纯大规模 RL 可涌现推理;R1 用冷启动数据修复可读性与稳定性。",
bridge: "从“模仿答案”转向用可验证奖励塑造推理策略。",
url: "https://arxiv.org/abs/2501.12948",
},
{
year: "2025.12",
model: "DeepSeek-V3.2",
idea: "DeepSeek Sparse Attention 降低长上下文成本,并统一 thinking 与 tool use。",
bridge: "把推理模型推进长上下文 Agent 场景。",
url: "https://arxiv.org/abs/2512.02556",
},
{
year: "2026.06",
model: "DeepSeek-V4",
idea: "围绕百万 Token 上下文效率继续扩展,成为 K3 报告直接比较的开放前沿之一。",
bridge: "长上下文不再只是位置外推,而是注意力、训练与服务的全系统问题。",
url: "https://arxiv.org/abs/2606.19348",
},
];
---
<div class="deepseek-lineage">
{milestones.map((item, index) => (
<a href={item.url} class="lineage-row" rel="noreferrer">
<div class="lineage-time">
<span>{item.year}</span>
<i aria-hidden="true"></i>
</div>
<div class="lineage-main">
<span>DS / {String(index + 1).padStart(2, "0")}</span>
<h3>{item.model}</h3>
<p>{item.idea}</p>
</div>
<div class="lineage-bridge">
<span>通向下一步</span>
<p>{item.bridge}</p>
</div>
</a>
))}
</div>
<style>
.deepseek-lineage {
max-width: 960px;
margin: 34px 0;
border-top: 1px solid var(--line);
}
.lineage-row {
display: grid;
grid-template-columns: 110px minmax(250px, 1fr) minmax(230px, 0.75fr);
gap: 28px;
padding: 28px 8px;
border-bottom: 1px solid var(--line);
text-decoration: none;
transition: background-color 160ms ease;
}
.lineage-row:hover {
background: var(--paper-raised);
}
.lineage-time {
display: grid;
grid-template-columns: 1fr 12px;
gap: 10px;
color: var(--copper);
font: 0.7rem/1.5 var(--mono);
}
.lineage-time i {
width: 8px;
height: 8px;
margin-top: 3px;
border: 2px solid var(--copper);
border-radius: 50%;
background: var(--paper);
}
.lineage-main > span,
.lineage-bridge > span {
color: var(--muted-light);
font: 0.6rem/1 var(--mono);
letter-spacing: 0.1em;
}
.lineage-main h3 {
margin: 10px 0 11px;
font-size: 1.25rem;
}
.lineage-main p,
.lineage-bridge p {
color: var(--muted);
font-size: 0.8rem;
line-height: 1.7;
}
.lineage-bridge {
padding-left: 24px;
border-left: 1px solid var(--line);
}
.lineage-bridge p {
margin-top: 10px;
}
@media (max-width: 720px) {
.lineage-row {
grid-template-columns: 75px 1fr;
}
.lineage-bridge {
grid-column: 2;
padding-left: 0;
border-left: 0;
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<footer class="site-footer">
<div>
<a class="footer-mark" href="/">L/A</a>
<p>从第一性原理到 Kimi K3:一套持续更新、证据可追溯的中文 LLM 技术课程。</p>
</div>
<div class="footer-links">
<a href="/roadmap/">学习地图</a>
<a href="/progress/">研究进度</a>
<a href="https://git.k1412.top/wuyang/llm-atlas" rel="noreferrer">开放源码</a>
<a href="https://github.com/MoonshotAI/Kimi-K3" rel="noreferrer">K3 官方报告</a>
</div>
<p class="footer-legal">代码 MIT · 原创内容 CC BY-SA 4.0 · 研究截止 2026-07-28</p>
</footer>
+50
View File
@@ -0,0 +1,50 @@
---
interface Props {
active?: string;
}
const { active = "" } = Astro.props;
const items = [
{ id: "home", href: "/", label: "首页" },
{ id: "roadmap", href: "/roadmap/", label: "学习地图" },
{ id: "k3", href: "/k3/", label: "K3 解剖" },
{ id: "deepseek", href: "/deepseek/", label: "DeepSeek" },
{ id: "foundations", href: "/foundations/", label: "基础原理" },
{ id: "papers", href: "/papers/", label: "论文库" },
{ id: "progress", href: "/progress/", label: "进度" },
];
---
<header class="site-header" id="top">
<a class="wordmark" href="/" aria-label="LLM Atlas 首页">
<span class="wordmark-mark">L/A</span>
<span class="wordmark-copy">
<b>LLM ATLAS</b>
<small>FROM FIRST PRINCIPLES TO KIMI K3</small>
</span>
</a>
<nav class="top-nav" aria-label="主要导航">
{items.map((item) => (
<a href={item.href} aria-current={active === item.id ? "page" : undefined}>{item.label}</a>
))}
</nav>
<div class="header-meta">
<span>RESEARCH · 2026</span>
<button id="menu-toggle" class="menu-toggle" type="button" aria-expanded="false" aria-controls="mobile-nav">目录</button>
</div>
<nav id="mobile-nav" class="mobile-nav" aria-label="移动导航">
{items.map((item) => (
<a href={item.href} aria-current={active === item.id ? "page" : undefined}>{item.label}</a>
))}
</nav>
</header>
<script>
const toggle = document.querySelector<HTMLButtonElement>("#menu-toggle");
const nav = document.querySelector<HTMLElement>("#mobile-nav");
toggle?.addEventListener("click", () => {
const open = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", String(!open));
nav?.toggleAttribute("data-open", !open);
});
</script>