feat: improve desktop reliability and customization
Address reliability and consistency gaps across Agent Runtimes, persistence, settings, Knowledge, Magic Notes, Smart Heartbeat, and the download site. Runtime processes now have bounded lifecycle cleanup and atomic configuration rollback, while model packages and persisted mutations recover safely. Add a configurable global shortcut, protect unsaved work, improve modal and keyboard behavior, localize the built-in project without rewriting stored data, and lazy-load heavy renderer routes under enforced bundle budgets. Align project forms and disabled controls with shared typography and interaction states, and strengthen website release metadata validation and navigation accessibility. Release note: 修复 Runtime、设置、知识库、魔法笔记与智能心跳中的可靠性和交互一致性问题;新增可配置全局快捷键,改进无障碍与加载性能,并强化官网下载校验。
This commit is contained in:
+66
-12
@@ -1,14 +1,16 @@
|
||||
# GoodBuddy 静态官网
|
||||
|
||||
`sites` 是无需构建步骤或额外依赖的静态官网源码,可直接托管整个目录。
|
||||
`sites` 是无需构建步骤或额外依赖的中英文静态官网源码,可直接托管整个目录。
|
||||
|
||||
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
|
||||
|
||||
首页将 GoodBuddy 定位为“桌面助手|AI 编程工具台”,优先展示三大桌面
|
||||
系统与双架构下载入口、统一 Agent Runtime,以及知识库、魔法笔记、
|
||||
智能心跳、桌面上下文和远程消息通道等桌面助手能力。下载区位于主要
|
||||
功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、鲲鹏和飞腾
|
||||
首页将 GoodBuddy 定位为“免注册、支持信创软硬件的一站式 AI 助手”,
|
||||
优先展示三大桌面系统与双架构下载入口、统一 Agent Runtime,以及知识库、
|
||||
魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。下载区位于
|
||||
主要功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、鲲鹏和飞腾
|
||||
对应的 Linux x64 / arm64 包。页面不重复设置底部下载推广区。
|
||||
英文页面位于 `en.html`,不展示信创适配文案,三个平台的下载按钮始终前往
|
||||
GitHub 最新正式 Release。
|
||||
首屏产品界面默认正面展示,在精确指针设备上使用克制的 3D 倾斜、
|
||||
柔和跟随光效和同步浮动标签;触屏设备保持静态布局,系统启用“减少动态
|
||||
效果”时不运行该交互。
|
||||
@@ -22,6 +24,14 @@
|
||||
设为 **GitHub Actions**。站点使用项目 Pages 地址,不需要 `CNAME` 文件
|
||||
或自定义域名 DNS 配置。
|
||||
|
||||
## 语言选择
|
||||
|
||||
首次访问时,`language.js` 使用浏览器的第一首选语言选择页面:中文语言进入
|
||||
中文首页,其他语言进入英文页。页头的语言按钮允许手动切换,并将选择保存在
|
||||
浏览器本地;之后访问优先使用手动选择。两个页面都声明 canonical 与
|
||||
`hreflang` alternate 地址。手动切换语言会保留当前 URL 片段,例如从下载区
|
||||
切换后仍停留在 `#download`。
|
||||
|
||||
## 本地预览
|
||||
|
||||
在仓库根目录运行:
|
||||
@@ -37,29 +47,73 @@ python -m http.server 4173 --bind 127.0.0.1 --directory sites
|
||||
```powershell
|
||||
node sites/scripts/validate.mjs
|
||||
node --check sites/app.js
|
||||
node --check sites/language.js
|
||||
node --check sites/release-index.js
|
||||
node --test sites/scripts/app.test.mjs sites/scripts/release-index.test.mjs
|
||||
```
|
||||
|
||||
校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式
|
||||
规则,以及下载选择器是否从受信任的正式发布索引加载并保留 GitHub
|
||||
Release 回退入口。
|
||||
校验脚本会检查中英文页面、语言选择、页内链接、本地资源、关键产品文案、
|
||||
主题与响应式规则,以及中文下载选择器是否从受信任的正式发布索引加载并
|
||||
保留 GitHub Release 回退入口。它还计算浅色弱文本与控件边框的 WCAG
|
||||
对比度、检查移动导航和下载控件结构、本地字体及许可证。发布索引测试覆盖
|
||||
严格 SemVer、六个目标、格式和扩展名、文件大小、SHA-256、唯一文件名及
|
||||
不可变 URL,并按发布生成器的实际命名绑定版本、平台、架构和格式。移动
|
||||
导航行为测试同时覆盖现代 MediaQueryList 监听与旧版 Safari 的 `addListener`
|
||||
回退。英文下载入口固定指向 GitHub Release。
|
||||
|
||||
## 下载入口
|
||||
|
||||
官网正文不写死版本号,页面启动后读取最新正式发布索引。
|
||||
Windows、macOS 和 Linux 下载卡片分别提供处理器架构与安装包类型选择器,
|
||||
选择后直接下载经过发布校验的不可变版本对象。发布索引请求失败、
|
||||
格式无效或返回非受信任的官方下载地址时,按钮继续指向 GitHub 最新正式
|
||||
Release:
|
||||
过大、发生重定向、格式无效或任一字段返回非受信任的官方下载地址时,
|
||||
整组按钮会以 fail-closed 方式继续指向 GitHub 最新正式 Release,不会混用
|
||||
部分 OSS 数据:
|
||||
|
||||
```text
|
||||
https://github.com/mesalogo/goodbuddy/releases/latest
|
||||
```
|
||||
|
||||
校验规则与桌面更新检查保持一致:索引只能指向稳定 SemVer 版本,必须恰好
|
||||
包含 Windows、macOS、Linux 的 x64 / arm64 六个匹配目标;每个目标必须提供
|
||||
准确的两种格式和扩展名、正的安全整数大小、64 字符小写十六进制 SHA-256、
|
||||
全局唯一文件名,以及位于
|
||||
`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${version}/`
|
||||
下、对文件名进行 URL 编码的精确地址。校验清单和 GitHub 回退地址也必须
|
||||
完全匹配,所有地址都不得包含凭据、端口、查询参数或片段。
|
||||
|
||||
安装包文件名必须与发布生成器完全一致:Windows 使用
|
||||
`GoodBuddy-${version}-windows-${arch}-setup.exe` 与
|
||||
`GoodBuddy-${version}-windows-${arch}-portable.zip`;macOS 使用
|
||||
`GoodBuddy-${version}-mac-${arch}.dmg|zip`;Linux x64 的 AppImage 与
|
||||
DEB 分别使用 electron-builder 的 `x86_64` 与 `amd64` 架构名,Linux
|
||||
arm64 使用 `arm64`。
|
||||
|
||||
## 字体与可访问性
|
||||
|
||||
站点随包提供约 48 KB 的 Inter Variable Latin 子集,不发起远程字体请求。
|
||||
拉丁字符优先使用该字体;中文依次使用系统提供的苹方、微软雅黑 UI、
|
||||
Noto Sans CJK SC 或思源黑体,并保留 `system-ui` 与无衬线回退。Inter 的
|
||||
SIL OFL 1.1 许可证位于 `assets/fonts/inter-OFL.txt`。
|
||||
|
||||
浅色主题的弱文本达到 WCAG AA 正文对比度,控件边框达到至少 3:1;
|
||||
站点也支持系统强制颜色与减少动态效果模式。动态替换下载链接时会保留
|
||||
“在新窗口打开”的屏幕阅读器说明。移动导航打开后会暂时将页头外内容设为
|
||||
`inert` 并聚焦第一个导航项;关闭时安全恢复原有 `inert` 状态和菜单按钮
|
||||
焦点,切换回桌面宽度也会解除隔离。媒体查询监听兼容现代浏览器和使用
|
||||
`MediaQueryList.addListener` 的旧版 Safari。
|
||||
|
||||
## 文件
|
||||
|
||||
- `index.html`:页面结构与简体中文内容
|
||||
- `en.html`:不包含信创适配文案的英文页面
|
||||
- `styles.css`:语义令牌、浅深主题、焦点与响应式布局
|
||||
- `app.js`:主题、移动导航和当前章节
|
||||
- `app.js`:主题、移动导航、当前章节和中文下载索引
|
||||
- `language.js`:浏览器语言自动选择与手动语言偏好
|
||||
- `release-index.js`:中文下载索引的严格、整页 fail-closed 校验
|
||||
- `assets/goodbuddy-light.png`、`assets/goodbuddy-dark.png`:由 `npm run icons` 与桌面应用同步生成的官方品牌图标
|
||||
- `assets/linux-plain.svg`:Devicon v2.17.0 提供的黑白 Linux 图标,许可见 `assets/devicon-LICENSE`
|
||||
- `scripts/validate.mjs`:无依赖静态检查
|
||||
- `assets/fonts/inter-latin-variable.woff2`、`assets/fonts/inter-OFL.txt`:本地 Inter Variable Latin 子集及许可证
|
||||
- `scripts/validate.mjs`:无依赖静态与对比度检查
|
||||
- `scripts/app.test.mjs`:移动导航、焦点、内容隔离和媒体查询兼容性回归测试
|
||||
- `scripts/release-index.test.mjs`:发布索引行为回归测试
|
||||
|
||||
+207
-66
@@ -5,6 +5,7 @@
|
||||
const header = document.querySelector("[data-site-header]");
|
||||
const menuToggle = document.querySelector("[data-menu-toggle]");
|
||||
const navigation = document.querySelector("[data-navigation]");
|
||||
const menuBackdrop = document.querySelector("[data-menu-backdrop]");
|
||||
const themeToggle = document.querySelector("[data-theme-toggle]");
|
||||
const themeColor = document.querySelector('meta[name="theme-color"]');
|
||||
const tiltStage = document.querySelector("[data-tilt-stage]");
|
||||
@@ -12,14 +13,30 @@
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const mobileMenu = window.matchMedia("(max-width: 719px)");
|
||||
const isEnglish = root.lang.toLowerCase().startsWith("en");
|
||||
const releaseManifestUrl =
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
|
||||
const releaseFallbackUrl =
|
||||
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
||||
const releaseStatus = document.querySelector("[data-release-status]");
|
||||
const releaseRequestTimeoutMs = 10_000;
|
||||
const releaseIndexApi = window.GoodBuddyReleaseIndex;
|
||||
const downloadCards = [
|
||||
...document.querySelectorAll("[data-download-card]"),
|
||||
];
|
||||
const interfaceCopy = isEnglish
|
||||
? {
|
||||
themeDark: "Switch to dark theme",
|
||||
themeLight: "Switch to light theme",
|
||||
menuOpen: "Open navigation",
|
||||
menuClose: "Close navigation",
|
||||
}
|
||||
: {
|
||||
themeDark: "切换为深色主题",
|
||||
themeLight: "切换为浅色主题",
|
||||
menuOpen: "打开导航",
|
||||
menuClose: "关闭导航",
|
||||
};
|
||||
const platformNames = {
|
||||
windows: "Windows",
|
||||
macos: "macOS",
|
||||
@@ -39,29 +56,93 @@
|
||||
return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const isTrustedReleaseUrl = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === "goodbuddy.oss-cn-beijing.aliyuncs.com" &&
|
||||
url.pathname.startsWith("/releases/")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
const listenMediaQuery = (query, listener) => {
|
||||
if (typeof query.addEventListener === "function") {
|
||||
query.addEventListener("change", listener);
|
||||
} else if (typeof query.addListener === "function") {
|
||||
query.addListener(listener);
|
||||
}
|
||||
};
|
||||
|
||||
const configureDownloads = (release) => {
|
||||
if (
|
||||
release?.formatVersion !== 1 ||
|
||||
release?.productName !== "GoodBuddy" ||
|
||||
typeof release?.version !== "string" ||
|
||||
!release?.targets
|
||||
) {
|
||||
throw new Error("发布索引格式无效");
|
||||
const readBoundedJson = async (response) => {
|
||||
const maximumBytes = releaseIndexApi?.maximumIndexBytes;
|
||||
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
|
||||
throw new Error("发布索引大小上限无效");
|
||||
}
|
||||
|
||||
const declaredLength = response.headers.get("content-length");
|
||||
if (declaredLength !== null) {
|
||||
const parsedLength = Number(declaredLength);
|
||||
if (
|
||||
!Number.isSafeInteger(parsedLength) ||
|
||||
parsedLength < 0 ||
|
||||
parsedLength > maximumBytes
|
||||
) {
|
||||
throw new Error("发布索引响应大小无效");
|
||||
}
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("发布索引响应没有正文");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
length += result.value.byteLength;
|
||||
if (length > maximumBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error("发布索引响应过大");
|
||||
}
|
||||
chunks.push(result.value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
};
|
||||
|
||||
const setReleaseLink = (link, url, visibleText) => {
|
||||
const newWindowNotice = link.querySelector(".sr-only");
|
||||
link.href = url;
|
||||
link.replaceChildren(document.createTextNode(visibleText));
|
||||
if (newWindowNotice) {
|
||||
link.append(newWindowNotice);
|
||||
}
|
||||
};
|
||||
|
||||
const setFallbackDownloads = () => {
|
||||
for (const card of downloadCards) {
|
||||
const platform = card.dataset.downloadCard;
|
||||
const link = card.closest(".download-card")?.querySelector("[data-release-link]");
|
||||
const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
|
||||
if (link instanceof HTMLAnchorElement) {
|
||||
setReleaseLink(
|
||||
link,
|
||||
releaseFallbackUrl,
|
||||
`前往 GitHub 下载 ${platformNames[platform] ?? platform ?? ""} →`,
|
||||
);
|
||||
}
|
||||
if (meta instanceof HTMLElement) {
|
||||
meta.textContent = "请在 GitHub Release 中选择对应的安装文件。";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const configureDownloads = (payload) => {
|
||||
if (!releaseIndexApi?.validateReleaseIndex) {
|
||||
throw new Error("发布索引校验器不可用");
|
||||
}
|
||||
const release = releaseIndexApi.validateReleaseIndex(payload);
|
||||
|
||||
const updateCard = (card) => {
|
||||
const platform = card.dataset.downloadCard;
|
||||
const archSelect = card.querySelector("[data-download-arch]");
|
||||
@@ -75,26 +156,15 @@
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
!(meta instanceof HTMLElement)
|
||||
) {
|
||||
return;
|
||||
throw new Error("下载卡片结构无效");
|
||||
}
|
||||
|
||||
const target = release.targets[`${platform}-${archSelect.value}`];
|
||||
const file = target?.files?.[formatSelect.value];
|
||||
if (
|
||||
!file ||
|
||||
typeof file.name !== "string" ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 1 ||
|
||||
!isTrustedReleaseUrl(file.url)
|
||||
) {
|
||||
link.href = releaseFallbackUrl;
|
||||
link.textContent =
|
||||
`前往 GitHub 下载 ${platformNames[platform] ?? platform} →`;
|
||||
meta.textContent = "当前选项暂不可用,请在 GitHub Release 中选择文件。";
|
||||
return;
|
||||
if (!file) {
|
||||
throw new Error("下载选项不在已校验的发布索引中");
|
||||
}
|
||||
|
||||
link.href = file.url;
|
||||
const platformName = platformNames[platform] ?? platform;
|
||||
const archName =
|
||||
platform === "macos" && archSelect.value === "arm64"
|
||||
@@ -103,7 +173,11 @@
|
||||
? "ARM64"
|
||||
: "x64";
|
||||
const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
|
||||
link.textContent = `下载 ${platformName} ${archName} ${formatName} →`;
|
||||
setReleaseLink(
|
||||
link,
|
||||
file.url,
|
||||
`下载 ${platformName} ${archName} ${formatName} →`,
|
||||
);
|
||||
meta.textContent =
|
||||
`GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` +
|
||||
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
|
||||
@@ -112,35 +186,40 @@
|
||||
for (const card of downloadCards) {
|
||||
const selects = card.querySelectorAll("select");
|
||||
for (const select of selects) {
|
||||
select.addEventListener("change", () => updateCard(card));
|
||||
select.addEventListener("change", () => {
|
||||
try {
|
||||
updateCard(card);
|
||||
} catch {
|
||||
setFallbackDownloads();
|
||||
}
|
||||
});
|
||||
}
|
||||
updateCard(card);
|
||||
}
|
||||
|
||||
if (releaseStatus instanceof HTMLElement) {
|
||||
releaseStatus.textContent =
|
||||
`官方下载源已就绪:GoodBuddy ${release.version}。` +
|
||||
"请选择处理器和安装包类型。";
|
||||
releaseStatus.classList.add("is-ready");
|
||||
}
|
||||
};
|
||||
|
||||
const loadRelease = async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
releaseRequestTimeoutMs,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(releaseManifestUrl, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`发布索引请求失败:${response.status}`);
|
||||
}
|
||||
configureDownloads(await response.json());
|
||||
configureDownloads(await readBoundedJson(response));
|
||||
} catch {
|
||||
if (releaseStatus instanceof HTMLElement) {
|
||||
releaseStatus.textContent =
|
||||
"官方下载源暂不可用,下载按钮已切换到 GitHub Release。";
|
||||
releaseStatus.classList.add("is-fallback");
|
||||
}
|
||||
setFallbackDownloads();
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +236,7 @@
|
||||
root.dataset.theme = theme;
|
||||
themeToggle?.setAttribute(
|
||||
"aria-label",
|
||||
theme === "dark" ? "切换为浅色主题" : "切换为深色主题",
|
||||
theme === "dark" ? interfaceCopy.themeLight : interfaceCopy.themeDark,
|
||||
);
|
||||
themeColor?.setAttribute("content", theme === "dark" ? "#07101f" : "#f6f8fb");
|
||||
|
||||
@@ -170,10 +249,59 @@
|
||||
}
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
let isolatedMenuContent = null;
|
||||
|
||||
const isolateMenuContent = () => {
|
||||
if (isolatedMenuContent) {
|
||||
return;
|
||||
}
|
||||
isolatedMenuContent = new Map();
|
||||
for (const element of document.body.children) {
|
||||
if (
|
||||
element === header ||
|
||||
element === menuBackdrop ||
|
||||
element instanceof HTMLScriptElement
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
isolatedMenuContent.set(element, element.inert);
|
||||
element.inert = true;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreMenuContent = () => {
|
||||
if (!isolatedMenuContent) {
|
||||
return;
|
||||
}
|
||||
for (const [element, wasInert] of isolatedMenuContent) {
|
||||
element.inert = wasInert;
|
||||
}
|
||||
isolatedMenuContent = null;
|
||||
};
|
||||
|
||||
const closeMenu = ({ restoreFocus = true } = {}) => {
|
||||
const wasOpen = header?.classList.contains("is-menu-open") ?? false;
|
||||
header?.classList.remove("is-menu-open");
|
||||
menuToggle?.setAttribute("aria-expanded", "false");
|
||||
menuToggle?.setAttribute("aria-label", "打开导航");
|
||||
menuToggle?.setAttribute("aria-label", interfaceCopy.menuOpen);
|
||||
menuBackdrop?.classList.remove("is-active");
|
||||
restoreMenuContent();
|
||||
if (wasOpen && restoreFocus) {
|
||||
menuToggle?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
if (!mobileMenu.matches) {
|
||||
closeMenu({ restoreFocus: false });
|
||||
return;
|
||||
}
|
||||
header?.classList.add("is-menu-open");
|
||||
menuToggle?.setAttribute("aria-expanded", "true");
|
||||
menuToggle?.setAttribute("aria-label", interfaceCopy.menuClose);
|
||||
menuBackdrop?.classList.add("is-active");
|
||||
isolateMenuContent();
|
||||
navigation?.querySelector("a")?.focus();
|
||||
};
|
||||
|
||||
const setHeaderState = () => {
|
||||
@@ -182,35 +310,37 @@
|
||||
|
||||
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
|
||||
setHeaderState();
|
||||
void loadRelease();
|
||||
if (!isEnglish) {
|
||||
void loadRelease();
|
||||
}
|
||||
|
||||
themeToggle?.addEventListener("click", () => {
|
||||
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
|
||||
});
|
||||
|
||||
systemTheme.addEventListener("change", (event) => {
|
||||
if (!getSavedTheme()) {
|
||||
applyTheme(event.matches ? "dark" : "light");
|
||||
}
|
||||
});
|
||||
|
||||
menuToggle?.addEventListener("click", () => {
|
||||
const willOpen = !header?.classList.contains("is-menu-open");
|
||||
header?.classList.toggle("is-menu-open", willOpen);
|
||||
menuToggle.setAttribute("aria-expanded", String(willOpen));
|
||||
menuToggle.setAttribute("aria-label", willOpen ? "关闭导航" : "打开导航");
|
||||
if (header?.classList.contains("is-menu-open")) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
navigation?.addEventListener("click", (event) => {
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
closeMenu();
|
||||
closeMenu({ restoreFocus: false });
|
||||
window.setTimeout(() => {
|
||||
if (!header?.classList.contains("is-menu-open")) {
|
||||
menuToggle?.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
menuBackdrop?.addEventListener("click", () => closeMenu());
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && header?.classList.contains("is-menu-open")) {
|
||||
closeMenu();
|
||||
menuToggle?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -224,6 +354,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
listenMediaQuery(systemTheme, (event) => {
|
||||
if (!getSavedTheme()) {
|
||||
applyTheme(event.matches ? "dark" : "light");
|
||||
}
|
||||
});
|
||||
listenMediaQuery(mobileMenu, (event) => {
|
||||
if (!event.matches) {
|
||||
closeMenu({ restoreFocus: false });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", setHeaderState, { passive: true });
|
||||
|
||||
if (tiltStage instanceof HTMLElement && tiltCard instanceof HTMLElement) {
|
||||
@@ -266,8 +407,8 @@
|
||||
|
||||
tiltStage.addEventListener("pointermove", updateTilt, { passive: true });
|
||||
tiltStage.addEventListener("pointerleave", resetTilt);
|
||||
finePointer.addEventListener("change", resetTilt);
|
||||
reducedMotion.addEventListener("change", resetTilt);
|
||||
listenMediaQuery(finePointer, resetTilt);
|
||||
listenMediaQuery(reducedMotion, resetTilt);
|
||||
}
|
||||
|
||||
const sections = [...document.querySelectorAll("main section[id]")];
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
+451
@@ -0,0 +1,451 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="description"
|
||||
content="GoodBuddy is an all-in-one desktop AI assistant that requires no GoodBuddy account, with a unified Agent Runtime for direct models, OpenCode, Continue, and DeepSeek Harness."
|
||||
/>
|
||||
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/en.html" />
|
||||
<link rel="alternate" hreflang="zh-CN" href="https://mesalogo.github.io/goodbuddy/" />
|
||||
<link rel="alternate" hreflang="en" href="https://mesalogo.github.io/goodbuddy/en.html" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://mesalogo.github.io/goodbuddy/en.html" />
|
||||
<meta name="theme-color" content="#f6f8fb" />
|
||||
<title>GoodBuddy | All-in-one AI assistant, no account required</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="./assets/goodbuddy-light.png"
|
||||
type="image/png"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="./assets/goodbuddy-dark.png"
|
||||
type="image/png"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<script src="./language.js"></script>
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
const savedTheme = localStorage.getItem("goodbuddy-site-theme");
|
||||
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.dataset.theme =
|
||||
savedTheme === "light" || savedTheme === "dark"
|
||||
? savedTheme
|
||||
: systemDark
|
||||
? "dark"
|
||||
: "light";
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<header class="site-header" data-site-header>
|
||||
<div class="header-inner">
|
||||
<a class="brand" href="#home" aria-label="GoodBuddy home">
|
||||
<span class="brand-icon" aria-hidden="true">
|
||||
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
|
||||
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
|
||||
</span>
|
||||
<span>GoodBuddy</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="icon-button menu-toggle"
|
||||
type="button"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
aria-controls="site-navigation"
|
||||
data-menu-toggle
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<nav class="site-navigation" id="site-navigation" aria-label="Main navigation" data-navigation>
|
||||
<a href="#download">Download</a>
|
||||
<a href="#features">Agent Runtime</a>
|
||||
<a href="#assistant">Desktop assistant</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<a
|
||||
class="language-link"
|
||||
href="./index.html?lang=zh"
|
||||
lang="zh-CN"
|
||||
hreflang="zh-CN"
|
||||
aria-label="切换到中文"
|
||||
data-language-link
|
||||
>
|
||||
中
|
||||
</a>
|
||||
<button class="icon-button" type="button" aria-label="Switch to dark theme" data-theme-toggle>
|
||||
<svg class="theme-icon theme-icon--sun" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
<svg class="theme-icon theme-icon--moon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.4 14.6A8.5 8.5 0 0 1 9.4 3.6a8.5 8.5 0 1 0 11 11Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
class="button button--quiet header-github"
|
||||
href="https://github.com/mesalogo/goodbuddy"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub
|
||||
<span class="sr-only">(opens in a new window)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="menu-backdrop" aria-hidden="true" data-menu-backdrop></div>
|
||||
|
||||
<main id="main-content">
|
||||
<section class="hero section" id="home" aria-labelledby="hero-title">
|
||||
<div class="section-inner hero-grid">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
Windows · macOS · Linux
|
||||
</div>
|
||||
<h1 id="hero-title">
|
||||
No account required.<br />
|
||||
Your all-in-one<br />
|
||||
<span>AI assistant.</span>
|
||||
</h1>
|
||||
<p class="hero-lead">
|
||||
GoodBuddy brings conversations, knowledge, notes, and tasks together in a desktop
|
||||
assistant and AI coding workspace. Its unified Agent Runtime connects direct models,
|
||||
OpenCode, Continue, and DeepSeek Harness without repeated command-line setup.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button button--primary" href="#download">Download now</a>
|
||||
<a class="button button--secondary" href="#features">Explore Agent Runtime</a>
|
||||
</div>
|
||||
<ul class="hero-facts" aria-label="Product highlights">
|
||||
<li>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
||||
Three desktop platforms, x64 and arm64
|
||||
</li>
|
||||
<li>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
||||
Four Agent Runtime options
|
||||
</li>
|
||||
<li>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
||||
No GoodBuddy account required
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="product-stage"
|
||||
role="img"
|
||||
aria-label="GoodBuddy desktop app showing an AI coding task running through the unified Agent Runtime"
|
||||
data-tilt-stage
|
||||
>
|
||||
<div class="stage-glow stage-glow--one"></div>
|
||||
<div class="stage-glow stage-glow--two"></div>
|
||||
<div class="app-window" data-tilt-card>
|
||||
<div class="window-bar">
|
||||
<div class="window-dots" aria-hidden="true"><span></span><span></span><span></span></div>
|
||||
<div class="window-title">GoodBuddy</div>
|
||||
<div class="window-status"><span></span> Runtime connected</div>
|
||||
</div>
|
||||
<div class="app-layout">
|
||||
<aside class="app-sidebar" aria-hidden="true">
|
||||
<div class="mini-brand">
|
||||
<span class="brand-icon" aria-hidden="true">
|
||||
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
|
||||
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="side-item is-active"><span></span>Chat</div>
|
||||
<div class="side-item"><span></span>Knowledge</div>
|
||||
<div class="side-item"><span></span>Notes</div>
|
||||
<div class="side-item"><span></span>Heartbeat</div>
|
||||
<div class="side-item"><span></span>Run history</div>
|
||||
<div class="sidebar-spacer"></div>
|
||||
<div class="side-item"><span></span>Settings</div>
|
||||
</aside>
|
||||
<div class="app-content">
|
||||
<div class="app-content-header">
|
||||
<div>
|
||||
<strong>Fix cross-platform build</strong>
|
||||
<span>Project: Desktop client</span>
|
||||
</div>
|
||||
<div class="mode-pill">Continue · Execute</div>
|
||||
</div>
|
||||
<div class="message-area">
|
||||
<div class="message message--user">Fix the build and validate all three desktop platforms.</div>
|
||||
<div class="message message--assistant">
|
||||
<div class="assistant-label">
|
||||
<span class="assistant-avatar" aria-hidden="true">
|
||||
<span class="brand-icon">
|
||||
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
|
||||
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
|
||||
</span>
|
||||
</span>
|
||||
<strong>GoodBuddy</strong>
|
||||
</div>
|
||||
<p>Agent Runtime loaded the project, Skills, and controlled tools.</p>
|
||||
<div class="tool-card">
|
||||
<div class="tool-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7" /></svg>
|
||||
</div>
|
||||
<div><strong>Preparing environment</strong><span>Continue · Project scope · Controlled tools</span></div>
|
||||
<span class="tool-state">Ready</span>
|
||||
</div>
|
||||
<div class="plan-lines" aria-hidden="true"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<span>Describe your coding task…</span>
|
||||
<div class="composer-actions"><span>Execute</span><b>↑</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="floating-card floating-card--approval">
|
||||
<span class="floating-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 5 6v5c0 4.5 2.8 8.6 7 10 4.2-1.4 7-5.5 7-10V6l-7-3Z" /><path d="m9 12 2 2 4-4" /></svg>
|
||||
</span>
|
||||
<span><strong>Unified Agent Runtime</strong><small>Direct models · OpenCode · Continue · DSH</small></span>
|
||||
</div>
|
||||
<div class="floating-card floating-card--scope">
|
||||
<span class="scope-dot"></span>
|
||||
<span><strong>Cross-platform</strong><small>Windows · macOS · Linux</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="proof-strip" aria-label="Platform and Runtime support">
|
||||
<div class="section-inner proof-grid">
|
||||
<div><strong>4 Runtimes</strong><span>Multiple AI coding paths</span></div>
|
||||
<div><strong>3 platforms</strong><span>Windows / macOS / Linux</span></div>
|
||||
<div><strong>2 architectures</strong><span>x64 / arm64</span></div>
|
||||
<div><strong>1 workspace</strong><span>Select, configure, run, audit</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section download-section" id="download" aria-label="Cross-platform downloads">
|
||||
<div class="section-inner">
|
||||
<div class="download-grid">
|
||||
<article class="download-card">
|
||||
<div class="platform-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m3 5 8-1v8H3V5Zm10-1.3L21 3v9h-8V3.7ZM3 14h8v8l-8-1v-7Zm10 0h8v9l-8-1v-8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div><h3>Windows</h3><p>x64 / arm64 · Installer / portable ZIP</p></div>
|
||||
<a
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Download from GitHub →<span class="sr-only">(opens in a new window)</span></a>
|
||||
</article>
|
||||
<article class="download-card">
|
||||
<div class="platform-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M16.8 12.7c0-2.7 2.2-4 2.3-4.1A5 5 0 0 0 15.2 6c-1.7-.2-3.2 1-4.1 1-.9 0-2.2-1-3.6-1-1.8 0-3.5 1.1-4.5 2.7-2 3.5-.5 8.7 1.4 11.5.9 1.4 2 2.8 3.5 2.7 1.4 0 1.9-.9 3.7-.9 1.7 0 2.2.9 3.7.9s2.5-1.4 3.4-2.7a10 10 0 0 0 1.6-3.3 4.6 4.6 0 0 1-3.5-4.2ZM14.1 4.3A4.7 4.7 0 0 0 15.2 1a4.8 4.8 0 0 0-3.1 1.6A4.4 4.4 0 0 0 11 5.8c1.2.1 2.3-.5 3.1-1.5Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div><h3>macOS</h3><p>Apple silicon / Intel · DMG / ZIP</p></div>
|
||||
<a
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Download from GitHub →<span class="sr-only">(opens in a new window)</span></a>
|
||||
</article>
|
||||
<article class="download-card">
|
||||
<div class="platform-icon">
|
||||
<img src="./assets/linux-plain.svg" alt="" />
|
||||
</div>
|
||||
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div>
|
||||
<a
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Download from GitHub →<span class="sr-only">(opens in a new window)</span></a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section features-section" id="features" aria-labelledby="features-title">
|
||||
<div class="section-inner">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="kicker">Unified Agent Runtime</p>
|
||||
<h2 id="features-title">Different tools, one workflow</h2>
|
||||
</div>
|
||||
<p>
|
||||
Bring Runtime selection, model connections, Skills, MCP, and permissions into one desktop interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-grid">
|
||||
<article class="feature-card feature-card--wide feature-card--accent">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="feature-number">01</span>
|
||||
<h3>One entry point for multiple Agent Runtimes</h3>
|
||||
<p>Choose direct models, OpenCode, Continue, or DeepSeek Harness for each task without learning a new entry point.</p>
|
||||
<div class="provider-pills" aria-label="Supported Agent Runtimes">
|
||||
<span>Direct models</span><span>OpenCode</span><span>Continue</span><span>DeepSeek Harness</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 5h16v14H4zM8 22h8M12 19v3" />
|
||||
<path d="M7 9h2M11 9h2M15 9h2M7 13h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="feature-number">02</span>
|
||||
<h3>Built for desktop platforms</h3>
|
||||
<p>Windows, macOS, and Linux releases are available for both x64 and arm64.</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 4h14v16H5zM8 8h8M8 12h5" />
|
||||
<path d="m14 16 2 2 3-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="feature-number">03</span>
|
||||
<h3>Lower setup overhead</h3>
|
||||
<p>Select the Runtime, model, work mode, and project in a graphical interface instead of memorizing commands.</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3 5 6v5c0 4.5 2.8 8.6 7 10 4.2-1.4 7-5.5 7-10V6l-7-3Z" />
|
||||
<path d="M9 12h6M12 9v6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="feature-number">04</span>
|
||||
<h3>Shared capabilities, preserved boundaries</h3>
|
||||
<p>Skills, MCP, and tools follow each Runtime. Ask stays read-only, while Execute remains approval-controlled and auditable.</p>
|
||||
<div class="mode-row" aria-label="Two work modes">
|
||||
<span>Ask <small>Read-only</small></span>
|
||||
<span class="is-accent">Execute <small>Controlled</small></span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 6.5C4 5.1 5.1 4 6.5 4H10l2 2h5.5C18.9 6 20 7.1 20 8.5v9c0 1.4-1.1 2.5-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5v-11Z" />
|
||||
<path d="M8 11h8M8 15h5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="feature-number">05</span>
|
||||
<h3>Project context in one place</h3>
|
||||
<p>Organize conversations, knowledge, tasks, and run history by project without losing context when switching Runtime.</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card feature-card--wide">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 13h3l2-6 4 12 2-6h5" />
|
||||
<path d="M4 4h16v16H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="feature-number">06</span>
|
||||
<h3>Trace every run from action to result</h3>
|
||||
<p>Review tool calls, cancellation, timeouts, token usage, and run history in one place.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section assistant-section" id="assistant" aria-labelledby="assistant-title">
|
||||
<div class="section-inner assistant-grid">
|
||||
<div class="assistant-intro">
|
||||
<div class="assistant-mark" aria-hidden="true">
|
||||
<span class="brand-icon">
|
||||
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
|
||||
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="kicker">Desktop assistant</p>
|
||||
<h2 id="assistant-title">Conversations, knowledge, notes, and tasks on your desktop</h2>
|
||||
<p>
|
||||
GoodBuddy keeps reference material, to-dos, and long-running work in one desktop workspace,
|
||||
so everyday assistance and AI coding share the same project context.
|
||||
</p>
|
||||
<a class="text-link" href="#download">
|
||||
Choose your desktop release
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="assistant-list">
|
||||
<article>
|
||||
<span class="assistant-number">01</span>
|
||||
<div><h3>Turn sources into searchable knowledge</h3><p>Import files, folders, and web pages, then search with full text, vectors, and a knowledge graph.</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<span class="assistant-number">02</span>
|
||||
<div><h3>Notes, to-dos, and long-term follow-up</h3><p>Magic Notes captures ideas and tasks, while Heartbeat reviews progress, builds memory, and proposes next steps.</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<span class="assistant-number">03</span>
|
||||
<div><h3>Understand what is on your desktop</h3><p>Add files, screenshots, app windows, clipboard content, and offline voice input when needed.</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<span class="assistant-number">04</span>
|
||||
<div><h3>Keep working away from your computer</h3><p>Connect messaging channels to separate conversations and hand tasks to GoodBuddy on your desktop.</p></div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="section-inner footer-inner">
|
||||
<a class="brand brand--footer" href="#home" aria-label="Back to GoodBuddy home">
|
||||
<span class="brand-icon" aria-hidden="true">
|
||||
<img class="brand-icon__image brand-icon__image--light" src="./assets/goodbuddy-light.png" alt="" />
|
||||
<img class="brand-icon__image brand-icon__image--dark" src="./assets/goodbuddy-dark.png" alt="" />
|
||||
</span>
|
||||
<span>GoodBuddy</span>
|
||||
</a>
|
||||
<p>No account required. One AI assistant for everything.</p>
|
||||
<div class="footer-links">
|
||||
<a href="#download">Download</a>
|
||||
<a href="#features">Agent Runtime</a>
|
||||
<a href="#assistant">Desktop assistant</a>
|
||||
<a href="https://github.com/mesalogo/goodbuddy" target="_blank" rel="noreferrer">
|
||||
GitHub<span class="sr-only">(opens in a new window)</span>
|
||||
</a>
|
||||
</div>
|
||||
<small>© <span data-current-year></span> GoodBuddy. This site uses no third-party analytics.</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+30
-14
@@ -5,11 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="description"
|
||||
content="GoodBuddy 是跨平台桌面助手与 AI 编程工具台,以统一 Agent Runtime 连接直连模型、OpenCode、Continue 与 DeepSeek Harness。"
|
||||
content="GoodBuddy 是免注册、支持信创软硬件的一站式 AI 助手,以统一 Agent Runtime 连接直连模型、OpenCode、Continue 与 DeepSeek Harness。"
|
||||
/>
|
||||
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/" />
|
||||
<link rel="alternate" hreflang="zh-CN" href="https://mesalogo.github.io/goodbuddy/" />
|
||||
<link rel="alternate" hreflang="en" href="https://mesalogo.github.io/goodbuddy/en.html" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://mesalogo.github.io/goodbuddy/en.html" />
|
||||
<meta name="theme-color" content="#f6f8fb" />
|
||||
<title>GoodBuddy|桌面助手与 AI 编程工具台</title>
|
||||
<title>GoodBuddy|免注册、支持信创软硬件的一站式 AI 助手</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="./assets/goodbuddy-light.png"
|
||||
@@ -23,6 +26,7 @@
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<script src="./language.js"></script>
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
@@ -73,6 +77,16 @@
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<a
|
||||
class="language-link"
|
||||
href="./en.html?lang=en"
|
||||
lang="en"
|
||||
hreflang="en"
|
||||
aria-label="Switch to English"
|
||||
data-language-link
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
<button class="icon-button" type="button" aria-label="切换为深色主题" data-theme-toggle>
|
||||
<svg class="theme-icon theme-icon--sun" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
@@ -94,6 +108,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="menu-backdrop" aria-hidden="true" data-menu-backdrop></div>
|
||||
|
||||
<main id="main-content">
|
||||
<section class="hero section" id="home" aria-labelledby="hero-title">
|
||||
@@ -103,10 +118,14 @@
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
Windows · macOS · Linux
|
||||
</div>
|
||||
<h1 id="hero-title">桌面助手,<br /><span>也是 AI 编程工具台。</span></h1>
|
||||
<h1 id="hero-title">
|
||||
免注册,<br />
|
||||
支持信创软硬件的<br />
|
||||
<span>一站式 AI 助手。</span>
|
||||
</h1>
|
||||
<p class="hero-lead">
|
||||
GoodBuddy 管理对话、知识、笔记与任务,也通过独创的统一 Agent Runtime
|
||||
接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
|
||||
作为桌面助手与 AI 编程工具台,GoodBuddy 管理对话、知识、笔记与任务,
|
||||
也通过独创的统一 Agent Runtime 接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
|
||||
无需反复配置命令行,选择工具和项目即可开始。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
@@ -124,7 +143,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
||||
图形化配置,执行仍受控
|
||||
无需注册 GoodBuddy 账号
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -155,7 +174,7 @@
|
||||
<div class="side-item"><span></span>知识库</div>
|
||||
<div class="side-item"><span></span>魔法笔记</div>
|
||||
<div class="side-item"><span></span>智能心跳</div>
|
||||
<div class="side-item"><span></span>任务与活动</div>
|
||||
<div class="side-item"><span></span>运行记录</div>
|
||||
<div class="sidebar-spacer"></div>
|
||||
<div class="side-item"><span></span>设置</div>
|
||||
</aside>
|
||||
@@ -324,13 +343,9 @@
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="download-release-status" data-release-status role="status">
|
||||
正在连接官方下载源…
|
||||
</div>
|
||||
|
||||
<aside class="domestic-support" aria-labelledby="domestic-support-title">
|
||||
<div>
|
||||
<p class="kicker">国产化适配</p>
|
||||
<p class="kicker">信创软硬件支持</p>
|
||||
<h3 id="domestic-support-title">统信 UOS、银河麒麟,覆盖国产 x64 与 ARM64</h3>
|
||||
<p>使用对应的 Linux x64 或 arm64 安装包。</p>
|
||||
</div>
|
||||
@@ -435,7 +450,7 @@
|
||||
</div>
|
||||
<span class="feature-number">06</span>
|
||||
<h3>从运行到结果,全程可追踪</h3>
|
||||
<p>统一查看工具调用、取消、超时、Token 用量和活动记录,知道 Runtime 做了什么。</p>
|
||||
<p>统一查看工具调用、取消、超时、Token 用量和运行记录,知道 Runtime 做了什么。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,7 +511,7 @@
|
||||
</span>
|
||||
<span>GoodBuddy</span>
|
||||
</a>
|
||||
<p>桌面助手|AI 编程工具台</p>
|
||||
<p>免注册、支持信创软硬件的一站式 AI 助手</p>
|
||||
<div class="footer-links">
|
||||
<a href="#download">下载</a>
|
||||
<a href="#features">Agent Runtime</a>
|
||||
@@ -509,6 +524,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="./release-index.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const root = document.documentElement;
|
||||
const currentLanguage = root.lang.toLowerCase().startsWith("zh") ? "zh" : "en";
|
||||
const requestedLanguage = new URLSearchParams(window.location.search).get("lang");
|
||||
let savedLanguage = null;
|
||||
|
||||
if (requestedLanguage === "zh" || requestedLanguage === "en") {
|
||||
savedLanguage = requestedLanguage;
|
||||
try {
|
||||
localStorage.setItem("goodbuddy-site-language", requestedLanguage);
|
||||
} catch {
|
||||
// The requested language still applies to this navigation.
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const storedLanguage = localStorage.getItem("goodbuddy-site-language");
|
||||
if (storedLanguage === "zh" || storedLanguage === "en") {
|
||||
savedLanguage = storedLanguage;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the browser language when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
const preferredLanguage =
|
||||
navigator.languages?.[0] ?? navigator.language ?? "en";
|
||||
const targetLanguage =
|
||||
savedLanguage ?? (preferredLanguage.toLowerCase().startsWith("zh") ? "zh" : "en");
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const languageLink =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("[data-language-link]")
|
||||
: null;
|
||||
if (!(languageLink instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
}
|
||||
const targetUrl = new URL(languageLink.href, window.location.href);
|
||||
targetUrl.hash = window.location.hash;
|
||||
languageLink.href = targetUrl.href;
|
||||
});
|
||||
|
||||
if (targetLanguage !== currentLanguage) {
|
||||
const targetPath = targetLanguage === "zh" ? "./" : "./en.html";
|
||||
window.location.replace(`${targetPath}${window.location.hash}`);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,206 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const mirrorIndexUrl =
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
|
||||
const fallbackUrl =
|
||||
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
||||
const maximumIndexBytes = 512 * 1024;
|
||||
const semVerPattern =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+((?:[0-9a-zA-Z-]+)(?:\.[0-9a-zA-Z-]+)*))?$/u;
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||
const safeFileNamePattern = /^(?!\.{1,2}$)[^/\\\0]+$/u;
|
||||
const targetDefinitions = Object.freeze({
|
||||
"windows-x64": Object.freeze({
|
||||
platform: "windows",
|
||||
arch: "x64",
|
||||
formats: Object.freeze(["nsis", "portable"]),
|
||||
}),
|
||||
"windows-arm64": Object.freeze({
|
||||
platform: "windows",
|
||||
arch: "arm64",
|
||||
formats: Object.freeze(["nsis", "portable"]),
|
||||
}),
|
||||
"macos-x64": Object.freeze({
|
||||
platform: "macos",
|
||||
arch: "x64",
|
||||
formats: Object.freeze(["dmg", "zip"]),
|
||||
}),
|
||||
"macos-arm64": Object.freeze({
|
||||
platform: "macos",
|
||||
arch: "arm64",
|
||||
formats: Object.freeze(["dmg", "zip"]),
|
||||
}),
|
||||
"linux-x64": Object.freeze({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
formats: Object.freeze(["AppImage", "deb"]),
|
||||
}),
|
||||
"linux-arm64": Object.freeze({
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
formats: Object.freeze(["AppImage", "deb"]),
|
||||
}),
|
||||
});
|
||||
const targetKeys = Object.freeze(Object.keys(targetDefinitions));
|
||||
|
||||
const isRecord = (value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const hasExactKeys = (value, keys) => {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const actualKeys = Object.keys(value);
|
||||
return (
|
||||
actualKeys.length === keys.length &&
|
||||
keys.every((key) => Object.prototype.hasOwnProperty.call(value, key))
|
||||
);
|
||||
};
|
||||
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const canonicalFileName = (version, platform, arch, format) => {
|
||||
if (platform === "windows" && format === "nsis") {
|
||||
return `GoodBuddy-${version}-windows-${arch}-setup.exe`;
|
||||
}
|
||||
if (platform === "windows" && format === "portable") {
|
||||
return `GoodBuddy-${version}-windows-${arch}-portable.zip`;
|
||||
}
|
||||
|
||||
if (platform === "macos") {
|
||||
return `GoodBuddy-${version}-mac-${arch}.${format}`;
|
||||
}
|
||||
|
||||
const artifactArch =
|
||||
arch === "x64"
|
||||
? format === "AppImage"
|
||||
? "x86_64"
|
||||
: "amd64"
|
||||
: arch;
|
||||
return `GoodBuddy-${version}-linux-${artifactArch}.${format}`;
|
||||
};
|
||||
|
||||
const assertExactUrl = (value, expected, label) => {
|
||||
assert(typeof value === "string" && value.length <= 2_048, `${label} 无效`);
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${label} 无效`);
|
||||
}
|
||||
assert(
|
||||
value === expected &&
|
||||
url.href === expected &&
|
||||
url.protocol === "https:" &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!url.port &&
|
||||
!url.search &&
|
||||
!url.hash,
|
||||
`${label} 不是受信任的正式地址`,
|
||||
);
|
||||
};
|
||||
|
||||
const validateReleaseIndex = (index) => {
|
||||
assert(
|
||||
hasExactKeys(index, [
|
||||
"formatVersion",
|
||||
"productName",
|
||||
"version",
|
||||
"targets",
|
||||
"checksumUrl",
|
||||
"fallbackUrl",
|
||||
]),
|
||||
"发布索引结构无效",
|
||||
);
|
||||
assert(index.formatVersion === 1, "发布索引版本无效");
|
||||
assert(index.productName === "GoodBuddy", "发布索引产品名称无效");
|
||||
assert(
|
||||
typeof index.version === "string" && index.version.length <= 256,
|
||||
"发布版本无效",
|
||||
);
|
||||
const parsedVersion = semVerPattern.exec(index.version);
|
||||
assert(parsedVersion && !parsedVersion[4], "发布索引必须指向稳定 SemVer 版本");
|
||||
assert(
|
||||
hasExactKeys(index.targets, targetKeys),
|
||||
"发布索引必须包含且仅包含六个平台目标",
|
||||
);
|
||||
|
||||
const releaseBase = new URL(`v${index.version}/`, mirrorIndexUrl);
|
||||
const seenNames = new Set();
|
||||
const seenUrls = new Set();
|
||||
|
||||
for (const key of targetKeys) {
|
||||
const definition = targetDefinitions[key];
|
||||
const target = index.targets[key];
|
||||
assert(
|
||||
hasExactKeys(target, ["platform", "arch", "files"]) &&
|
||||
target.platform === definition.platform &&
|
||||
target.arch === definition.arch,
|
||||
`发布目标与键不匹配:${key}`,
|
||||
);
|
||||
assert(
|
||||
hasExactKeys(target.files, definition.formats),
|
||||
`发布目标文件格式无效:${key}`,
|
||||
);
|
||||
|
||||
for (const format of definition.formats) {
|
||||
const file = target.files[format];
|
||||
assert(
|
||||
hasExactKeys(file, ["name", "size", "sha256", "url"]),
|
||||
`发布文件结构无效:${key}/${format}`,
|
||||
);
|
||||
assert(
|
||||
typeof file.name === "string" &&
|
||||
file.name.length >= 1 &&
|
||||
file.name.length <= 255 &&
|
||||
safeFileNamePattern.test(file.name) &&
|
||||
file.name ===
|
||||
canonicalFileName(
|
||||
index.version,
|
||||
target.platform,
|
||||
target.arch,
|
||||
format,
|
||||
),
|
||||
`发布文件名或扩展名无效:${key}/${format}`,
|
||||
);
|
||||
assert(
|
||||
Number.isSafeInteger(file.size) && file.size > 0,
|
||||
`发布文件大小无效:${key}/${format}`,
|
||||
);
|
||||
assert(
|
||||
typeof file.sha256 === "string" && sha256Pattern.test(file.sha256),
|
||||
`发布文件校验值无效:${key}/${format}`,
|
||||
);
|
||||
assert(!seenNames.has(file.name), `发布文件名重复:${file.name}`);
|
||||
seenNames.add(file.name);
|
||||
|
||||
const expectedUrl = new URL(
|
||||
encodeURIComponent(file.name),
|
||||
releaseBase,
|
||||
).href;
|
||||
assertExactUrl(file.url, expectedUrl, "发布文件地址");
|
||||
assert(!seenUrls.has(file.url), `发布文件地址重复:${file.url}`);
|
||||
seenUrls.add(file.url);
|
||||
}
|
||||
}
|
||||
|
||||
assertExactUrl(
|
||||
index.checksumUrl,
|
||||
new URL("SHA256SUMS", releaseBase).href,
|
||||
"校验清单地址",
|
||||
);
|
||||
assertExactUrl(index.fallbackUrl, fallbackUrl, "GitHub 回退地址");
|
||||
return index;
|
||||
};
|
||||
|
||||
window.GoodBuddyReleaseIndex = Object.freeze({
|
||||
maximumIndexBytes,
|
||||
validateReleaseIndex,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const { test } = process.env.VITEST
|
||||
? await import("vitest")
|
||||
: await import("node:test");
|
||||
const source = await readFile(path.resolve("sites/app.js"), "utf8");
|
||||
|
||||
const createMediaQueries = (window, legacy) => {
|
||||
const queries = new Map();
|
||||
window.matchMedia = (media) => {
|
||||
if (!queries.has(media)) {
|
||||
const listeners = new Set();
|
||||
const query = {
|
||||
media,
|
||||
matches: media === "(max-width: 719px)",
|
||||
addEventListener: legacy
|
||||
? undefined
|
||||
: (_type, listener) => listeners.add(listener),
|
||||
addListener: legacy
|
||||
? (listener) => listeners.add(listener)
|
||||
: undefined,
|
||||
dispatch(matches) {
|
||||
query.matches = matches;
|
||||
for (const listener of listeners) {
|
||||
listener(query);
|
||||
}
|
||||
},
|
||||
};
|
||||
queries.set(media, query);
|
||||
}
|
||||
return queries.get(media);
|
||||
};
|
||||
return queries;
|
||||
};
|
||||
|
||||
const renderApp = (legacy = false) => {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta name="theme-color" content="#f6f8fb"></head>
|
||||
<body>
|
||||
<a id="skip" href="#main">Skip</a>
|
||||
<header data-site-header>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
data-menu-toggle
|
||||
>Menu</button>
|
||||
<nav data-navigation>
|
||||
<a id="first-nav-link" href="#download">Download</a>
|
||||
<a href="#features">Features</a>
|
||||
</nav>
|
||||
<button type="button" data-theme-toggle>Theme</button>
|
||||
</header>
|
||||
<div data-menu-backdrop></div>
|
||||
<main id="main">
|
||||
<section id="download"></section>
|
||||
<section id="features"></section>
|
||||
</main>
|
||||
<footer id="footer">Footer</footer>
|
||||
<span data-current-year></span>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
runScripts: "outside-only",
|
||||
url: "https://example.test/en.html?lang=en",
|
||||
},
|
||||
);
|
||||
const { window } = dom;
|
||||
const inertState = new WeakMap();
|
||||
Object.defineProperty(window.HTMLElement.prototype, "inert", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return inertState.get(this) ?? false;
|
||||
},
|
||||
set(value) {
|
||||
inertState.set(this, Boolean(value));
|
||||
},
|
||||
});
|
||||
const queries = createMediaQueries(window, legacy);
|
||||
window.eval(source);
|
||||
return { dom, queries, window };
|
||||
};
|
||||
|
||||
for (const legacy of [false, true]) {
|
||||
test(
|
||||
`mobile menu isolates content and restores focus with ${
|
||||
legacy ? "legacy" : "modern"
|
||||
} media listeners`,
|
||||
async () => {
|
||||
const { dom, queries, window } = renderApp(legacy);
|
||||
const header = window.document.querySelector("[data-site-header]");
|
||||
const toggle = window.document.querySelector("[data-menu-toggle]");
|
||||
const firstLink = window.document.querySelector("#first-nav-link");
|
||||
const main = window.document.querySelector("main");
|
||||
const footer = window.document.querySelector("footer");
|
||||
const skip = window.document.querySelector("#skip");
|
||||
const backdrop = window.document.querySelector("[data-menu-backdrop]");
|
||||
|
||||
main.inert = true;
|
||||
toggle.click();
|
||||
assert.equal(header.classList.contains("is-menu-open"), true);
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(window.document.activeElement, firstLink);
|
||||
assert.equal(skip.inert, true);
|
||||
assert.equal(main.inert, true);
|
||||
assert.equal(footer.inert, true);
|
||||
assert.equal(backdrop.inert, false);
|
||||
assert.equal(backdrop.classList.contains("is-active"), true);
|
||||
|
||||
window.document.dispatchEvent(
|
||||
new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
assert.equal(header.classList.contains("is-menu-open"), false);
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "false");
|
||||
assert.equal(window.document.activeElement, toggle);
|
||||
assert.equal(skip.inert, false);
|
||||
assert.equal(main.inert, true);
|
||||
assert.equal(footer.inert, false);
|
||||
assert.equal(backdrop.classList.contains("is-active"), false);
|
||||
|
||||
toggle.click();
|
||||
backdrop.click();
|
||||
assert.equal(header.classList.contains("is-menu-open"), false);
|
||||
assert.equal(window.document.activeElement, toggle);
|
||||
|
||||
toggle.click();
|
||||
footer.click();
|
||||
assert.equal(header.classList.contains("is-menu-open"), false);
|
||||
assert.equal(window.document.activeElement, toggle);
|
||||
|
||||
toggle.click();
|
||||
firstLink.click();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
assert.equal(header.classList.contains("is-menu-open"), false);
|
||||
assert.equal(window.document.activeElement, toggle);
|
||||
|
||||
toggle.click();
|
||||
queries.get("(max-width: 719px)").dispatch(false);
|
||||
assert.equal(header.classList.contains("is-menu-open"), false);
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "false");
|
||||
assert.equal(footer.inert, false);
|
||||
|
||||
dom.window.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const { test } = process.env.VITEST
|
||||
? await import("vitest")
|
||||
: await import("node:test");
|
||||
const source = await readFile(path.resolve("sites/release-index.js"), "utf8");
|
||||
const context = vm.createContext({ URL, window: {} });
|
||||
vm.runInContext(source, context, { filename: "release-index.js" });
|
||||
const { validateReleaseIndex } = context.window.GoodBuddyReleaseIndex;
|
||||
|
||||
const definitions = [
|
||||
["windows", "x64", ["nsis", "portable"]],
|
||||
["windows", "arm64", ["nsis", "portable"]],
|
||||
["macos", "x64", ["dmg", "zip"]],
|
||||
["macos", "arm64", ["dmg", "zip"]],
|
||||
["linux", "x64", ["AppImage", "deb"]],
|
||||
["linux", "arm64", ["AppImage", "deb"]],
|
||||
];
|
||||
|
||||
const canonicalFileName = (version, platform, arch, format) => {
|
||||
const names = {
|
||||
"windows-x64": {
|
||||
nsis: `GoodBuddy-${version}-windows-x64-setup.exe`,
|
||||
portable: `GoodBuddy-${version}-windows-x64-portable.zip`,
|
||||
},
|
||||
"windows-arm64": {
|
||||
nsis: `GoodBuddy-${version}-windows-arm64-setup.exe`,
|
||||
portable: `GoodBuddy-${version}-windows-arm64-portable.zip`,
|
||||
},
|
||||
"macos-x64": {
|
||||
dmg: `GoodBuddy-${version}-mac-x64.dmg`,
|
||||
zip: `GoodBuddy-${version}-mac-x64.zip`,
|
||||
},
|
||||
"macos-arm64": {
|
||||
dmg: `GoodBuddy-${version}-mac-arm64.dmg`,
|
||||
zip: `GoodBuddy-${version}-mac-arm64.zip`,
|
||||
},
|
||||
"linux-x64": {
|
||||
AppImage: `GoodBuddy-${version}-linux-x86_64.AppImage`,
|
||||
deb: `GoodBuddy-${version}-linux-amd64.deb`,
|
||||
},
|
||||
"linux-arm64": {
|
||||
AppImage: `GoodBuddy-${version}-linux-arm64.AppImage`,
|
||||
deb: `GoodBuddy-${version}-linux-arm64.deb`,
|
||||
},
|
||||
};
|
||||
return names[`${platform}-${arch}`][format];
|
||||
};
|
||||
|
||||
const validIndex = () => {
|
||||
const version = "1.2.3";
|
||||
const releaseBase =
|
||||
`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${version}/`;
|
||||
const targets = {};
|
||||
let fileNumber = 1;
|
||||
|
||||
for (const [platform, arch, formats] of definitions) {
|
||||
const files = {};
|
||||
for (const format of formats) {
|
||||
const name = canonicalFileName(version, platform, arch, format);
|
||||
files[format] = {
|
||||
name,
|
||||
size: 1_024 * fileNumber,
|
||||
sha256: fileNumber.toString(16).padStart(64, "0"),
|
||||
url: new URL(encodeURIComponent(name), releaseBase).href,
|
||||
};
|
||||
fileNumber += 1;
|
||||
}
|
||||
targets[`${platform}-${arch}`] = { platform, arch, files };
|
||||
}
|
||||
|
||||
return {
|
||||
formatVersion: 1,
|
||||
productName: "GoodBuddy",
|
||||
version,
|
||||
targets,
|
||||
checksumUrl: new URL("SHA256SUMS", releaseBase).href,
|
||||
fallbackUrl: "https://github.com/mesalogo/goodbuddy/releases/latest",
|
||||
};
|
||||
};
|
||||
|
||||
const expectRejected = (mutate) => {
|
||||
const index = validIndex();
|
||||
mutate(index);
|
||||
assert.throws(() => validateReleaseIndex(index));
|
||||
};
|
||||
|
||||
test("accepts the canonical stable six-target release index", () => {
|
||||
const index = validIndex();
|
||||
assert.equal(validateReleaseIndex(index), index);
|
||||
});
|
||||
|
||||
test("rejects unstable or non-strict versions and extra top-level fields", () => {
|
||||
for (const version of ["v1.2.3", "01.2.3", "1.2", "1.2.3-rc.1"]) {
|
||||
expectRejected((index) => {
|
||||
index.version = version;
|
||||
});
|
||||
}
|
||||
expectRejected((index) => {
|
||||
index.unexpected = true;
|
||||
});
|
||||
});
|
||||
|
||||
test("requires the exact six target keys and matching platform metadata", () => {
|
||||
expectRejected((index) => {
|
||||
delete index.targets["linux-arm64"];
|
||||
});
|
||||
expectRejected((index) => {
|
||||
index.targets["linux-arm64"].platform = "windows";
|
||||
});
|
||||
expectRejected((index) => {
|
||||
index.targets["unexpected-x64"] = index.targets["linux-arm64"];
|
||||
});
|
||||
});
|
||||
|
||||
test("requires exact formats, extensions, positive safe sizes, and SHA-256", () => {
|
||||
expectRejected((index) => {
|
||||
index.targets["windows-x64"].files.nsis.name = "GoodBuddy-1.2.3.zip";
|
||||
});
|
||||
expectRejected((index) => {
|
||||
index.targets["macos-arm64"].files.extra =
|
||||
index.targets["macos-arm64"].files.zip;
|
||||
});
|
||||
for (const size of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
expectRejected((index) => {
|
||||
index.targets["linux-x64"].files.deb.size = size;
|
||||
});
|
||||
}
|
||||
expectRejected((index) => {
|
||||
index.targets["linux-x64"].files.deb.sha256 = "A".repeat(64);
|
||||
});
|
||||
});
|
||||
|
||||
test("binds every filename to the indexed release version", () => {
|
||||
expectRejected((index) => {
|
||||
const file = index.targets["macos-arm64"].files.dmg;
|
||||
file.name = file.name.replace(index.version, "1.2.2");
|
||||
file.url = new URL(
|
||||
encodeURIComponent(file.name),
|
||||
`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${index.version}/`,
|
||||
).href;
|
||||
});
|
||||
});
|
||||
|
||||
test("binds filenames to their target platform and architecture", () => {
|
||||
expectRejected((index) => {
|
||||
const file = index.targets["macos-x64"].files.zip;
|
||||
file.name = file.name.replace("-mac-x64.zip", "-linux-x64.zip");
|
||||
file.url = new URL(
|
||||
encodeURIComponent(file.name),
|
||||
`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${index.version}/`,
|
||||
).href;
|
||||
});
|
||||
|
||||
expectRejected((index) => {
|
||||
const x64Files = index.targets["linux-x64"].files;
|
||||
index.targets["linux-x64"].files =
|
||||
index.targets["linux-arm64"].files;
|
||||
index.targets["linux-arm64"].files = x64Files;
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects swapped x64 and arm64 target records", () => {
|
||||
expectRejected((index) => {
|
||||
const x64Target = index.targets["windows-x64"];
|
||||
index.targets["windows-x64"] = index.targets["windows-arm64"];
|
||||
index.targets["windows-arm64"] = x64Target;
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects duplicate files and any non-canonical release URL", () => {
|
||||
expectRejected((index) => {
|
||||
const duplicate = index.targets["windows-x64"].files.nsis;
|
||||
index.targets["windows-arm64"].files.nsis.name = duplicate.name;
|
||||
index.targets["windows-arm64"].files.nsis.url = duplicate.url;
|
||||
});
|
||||
|
||||
for (const changeUrl of [
|
||||
(url) => url.replace("https://", "http://"),
|
||||
(url) => url.replace("goodbuddy.", "user:pass@goodbuddy."),
|
||||
(url) => url.replace(".com/", ".com:444/"),
|
||||
(url) => `${url}?download=1`,
|
||||
(url) => `${url}#asset`,
|
||||
(url) => url.replace("/releases/v1.2.3/", "/releases/v9.9.9/"),
|
||||
(url) => url.replace("GoodBuddy-", "OtherBuddy-"),
|
||||
]) {
|
||||
expectRejected((index) => {
|
||||
const file = index.targets["linux-arm64"].files.AppImage;
|
||||
file.url = changeUrl(file.url);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("requires exact checksum and GitHub fallback URLs", () => {
|
||||
expectRejected((index) => {
|
||||
index.checksumUrl += "?raw=1";
|
||||
});
|
||||
expectRejected((index) => {
|
||||
index.fallbackUrl = "https://github.com/mesalogo/goodbuddy/releases";
|
||||
});
|
||||
});
|
||||
+449
-46
@@ -7,12 +7,19 @@ const errors = [];
|
||||
|
||||
const requiredFiles = [
|
||||
"index.html",
|
||||
"en.html",
|
||||
"styles.css",
|
||||
"app.js",
|
||||
"language.js",
|
||||
"release-index.js",
|
||||
"assets/goodbuddy-light.png",
|
||||
"assets/goodbuddy-dark.png",
|
||||
"assets/linux-plain.svg",
|
||||
"assets/devicon-LICENSE",
|
||||
"assets/fonts/inter-latin-variable.woff2",
|
||||
"assets/fonts/inter-OFL.txt",
|
||||
"scripts/app.test.mjs",
|
||||
"scripts/release-index.test.mjs",
|
||||
"README.md",
|
||||
];
|
||||
|
||||
@@ -42,34 +49,92 @@ await Promise.all(
|
||||
}),
|
||||
);
|
||||
|
||||
const [html, css, appJs] = await Promise.all([
|
||||
readSiteFile("index.html"),
|
||||
readSiteFile("styles.css"),
|
||||
readSiteFile("app.js"),
|
||||
]);
|
||||
const [html, englishHtml, css, appJs, languageJs, releaseIndexJs, fontLicense] =
|
||||
await Promise.all([
|
||||
readSiteFile("index.html"),
|
||||
readSiteFile("en.html"),
|
||||
readSiteFile("styles.css"),
|
||||
readSiteFile("app.js"),
|
||||
readSiteFile("language.js"),
|
||||
readSiteFile("release-index.js"),
|
||||
readSiteFile("assets/fonts/inter-OFL.txt"),
|
||||
]);
|
||||
|
||||
for (const [relativePath, content] of [
|
||||
["index.html", html],
|
||||
["en.html", englishHtml],
|
||||
["styles.css", css],
|
||||
["app.js", appJs],
|
||||
["language.js", languageJs],
|
||||
["release-index.js", releaseIndexJs],
|
||||
]) {
|
||||
report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`);
|
||||
report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`);
|
||||
}
|
||||
|
||||
report(/<html\s+lang="zh-CN">/.test(html), "页面语言必须是 zh-CN");
|
||||
report(/<html\s+lang="en">/.test(englishHtml), "英文页面语言必须是 en");
|
||||
report(/<meta\s+name="viewport"/.test(html), "缺少 viewport 元信息");
|
||||
report(/<meta\s+name="viewport"/.test(englishHtml), "英文页面缺少 viewport 元信息");
|
||||
report(
|
||||
/<link\s+rel="canonical"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/"\s*\/>/.test(
|
||||
html,
|
||||
),
|
||||
"canonical 地址必须指向 GitHub Pages 正式站点",
|
||||
);
|
||||
report(
|
||||
/<link\s+rel="canonical"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/en\.html"\s*\/>/.test(
|
||||
englishHtml,
|
||||
),
|
||||
"英文 canonical 地址必须指向 GitHub Pages 英文站点",
|
||||
);
|
||||
for (const [relativePath, content] of [
|
||||
["index.html", html],
|
||||
["en.html", englishHtml],
|
||||
]) {
|
||||
report(
|
||||
/hreflang="zh-CN"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/"/.test(content),
|
||||
`${relativePath} 缺少中文 alternate 链接`,
|
||||
);
|
||||
report(
|
||||
/hreflang="en"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/en\.html"/.test(
|
||||
content,
|
||||
),
|
||||
`${relativePath} 缺少英文 alternate 链接`,
|
||||
);
|
||||
report(
|
||||
/<script\s+src="\.\/language\.js"><\/script>/.test(content),
|
||||
`${relativePath} 缺少语言选择脚本`,
|
||||
);
|
||||
}
|
||||
report((html.match(/<h1[\s>]/g) ?? []).length === 1, "页面必须且只能包含一个 h1");
|
||||
report(
|
||||
(englishHtml.match(/<h1[\s>]/g) ?? []).length === 1,
|
||||
"英文页面必须且只能包含一个 h1",
|
||||
);
|
||||
report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接");
|
||||
report(
|
||||
/class="skip-link"\s+href="#main-content"/.test(englishHtml),
|
||||
"英文页面缺少跳到主要内容链接",
|
||||
);
|
||||
report(/<main\s+id="main-content">/.test(html), "缺少 main-content 主区域");
|
||||
report(/<main\s+id="main-content">/.test(englishHtml), "英文页面缺少 main-content 主区域");
|
||||
report(/aria-label="主导航"/.test(html), "主导航缺少可访问名称");
|
||||
report(/aria-label="Main navigation"/.test(englishHtml), "英文主导航缺少可访问名称");
|
||||
report(/data-theme-toggle/.test(html), "缺少主题切换控件");
|
||||
report(/data-theme-toggle/.test(englishHtml), "英文页面缺少主题切换控件");
|
||||
report(
|
||||
/href="\.\/en\.html\?lang=en"/.test(html),
|
||||
"中文页面缺少英文语言切换入口",
|
||||
);
|
||||
report(
|
||||
/href="\.\/index\.html\?lang=zh"/.test(englishHtml),
|
||||
"英文页面缺少中文语言切换入口",
|
||||
);
|
||||
report(
|
||||
/data-language-link/.test(html) && /data-language-link/.test(englishHtml),
|
||||
"中英文语言切换入口必须标记为保留片段的手动切换",
|
||||
);
|
||||
report(
|
||||
(html.match(/src="\.\/assets\/goodbuddy-light\.png"/g) ?? []).length >= 5,
|
||||
"品牌位置必须使用官方亮色图标",
|
||||
@@ -78,9 +143,20 @@ report(
|
||||
(html.match(/src="\.\/assets\/goodbuddy-dark\.png"/g) ?? []).length >= 5,
|
||||
"品牌位置必须使用官方深色图标",
|
||||
);
|
||||
report(
|
||||
(englishHtml.match(/src="\.\/assets\/goodbuddy-light\.png"/g) ?? []).length >= 5,
|
||||
"英文品牌位置必须使用官方亮色图标",
|
||||
);
|
||||
report(
|
||||
(englishHtml.match(/src="\.\/assets\/goodbuddy-dark\.png"/g) ?? []).length >= 5,
|
||||
"英文品牌位置必须使用官方深色图标",
|
||||
);
|
||||
report(!/class="brand-mark"/.test(html), "官网不得使用自绘品牌标志");
|
||||
report(!/class="brand-mark"/.test(englishHtml), "英文官网不得使用自绘品牌标志");
|
||||
report(/data-tilt-stage/.test(html), "首屏产品界面缺少倾斜交互区域");
|
||||
report(/data-tilt-stage/.test(englishHtml), "英文首屏产品界面缺少倾斜交互区域");
|
||||
report(/data-tilt-card/.test(html), "首屏产品界面缺少倾斜卡片");
|
||||
report(/data-tilt-card/.test(englishHtml), "英文首屏产品界面缺少倾斜卡片");
|
||||
report(/prefers-reduced-motion:\s*reduce/.test(css), "缺少减少动态效果规则");
|
||||
report(/\[data-theme="dark"\]/.test(css), "缺少深色主题令牌");
|
||||
report(/--scene-tilt-x/.test(css), "缺少产品界面横向倾斜变量");
|
||||
@@ -90,12 +166,90 @@ report(
|
||||
"浮动标签必须跟随产品界面倾斜",
|
||||
);
|
||||
report(/requestAnimationFrame/.test(appJs), "产品界面倾斜交互必须按帧更新");
|
||||
report(
|
||||
/@font-face[\s\S]*font-family:\s*"Inter Variable"[\s\S]*inter-latin-variable\.woff2/.test(
|
||||
css,
|
||||
),
|
||||
"官网必须使用本地 Inter Variable Latin 字体",
|
||||
);
|
||||
report(
|
||||
!/@import\s+url|fonts\.(?:googleapis|gstatic)\.com|https?:\/\/[^)"']+\.(?:woff2?|ttf)/iu.test(
|
||||
css,
|
||||
),
|
||||
"官网字体不得通过远程请求加载",
|
||||
);
|
||||
report(
|
||||
/SIL OPEN FONT LICENSE Version 1\.1/.test(fontLicense),
|
||||
"Inter 字体必须附带 OFL 1.1 许可证",
|
||||
);
|
||||
const fontStats = await stat(
|
||||
path.join(siteRoot, "assets/fonts/inter-latin-variable.woff2"),
|
||||
).catch(() => null);
|
||||
report(
|
||||
fontStats?.isFile() && fontStats.size >= 20_000 && fontStats.size <= 100_000,
|
||||
"Inter Latin 字体文件大小应保持在 20 KB 到 100 KB",
|
||||
);
|
||||
|
||||
const hexToLuminance = (hex) => {
|
||||
const channels = [1, 3, 5].map(
|
||||
(offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255,
|
||||
);
|
||||
const linear = channels.map((channel) =>
|
||||
channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4,
|
||||
);
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
};
|
||||
|
||||
const contrastRatio = (foreground, background) => {
|
||||
const foregroundLuminance = hexToLuminance(foreground);
|
||||
const backgroundLuminance = hexToLuminance(background);
|
||||
return (
|
||||
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
|
||||
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
|
||||
);
|
||||
};
|
||||
|
||||
const lightThemeBlock = css.match(/:root\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||
const getLightToken = (name) =>
|
||||
lightThemeBlock.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`))?.[1];
|
||||
const mutedColor = getLightToken("text-muted");
|
||||
const controlBorder = getLightToken("border-control");
|
||||
const raisedSurface = getLightToken("surface-raised");
|
||||
const subtleSurface = getLightToken("surface-subtle");
|
||||
const canvasSurface = getLightToken("surface-canvas");
|
||||
for (const [label, foreground, background, minimum] of [
|
||||
["浅色弱文本/画布", mutedColor, canvasSurface, 4.5],
|
||||
["浅色弱文本/卡片", mutedColor, raisedSurface, 4.5],
|
||||
["浅色弱文本/次级表面", mutedColor, subtleSurface, 4.5],
|
||||
["浅色控件边框/卡片", controlBorder, raisedSurface, 3],
|
||||
["浅色控件边框/次级表面", controlBorder, subtleSurface, 3],
|
||||
]) {
|
||||
report(
|
||||
foreground &&
|
||||
background &&
|
||||
contrastRatio(foreground, background) >= minimum,
|
||||
`${label} 对比度必须至少达到 ${minimum}:1`,
|
||||
);
|
||||
}
|
||||
for (const selector of ["language-link", "icon-button", "button--quiet"]) {
|
||||
report(
|
||||
new RegExp(
|
||||
`\\.${selector}\\s*\\{[^}]*border(?:-color)?:\\s*(?:1px solid )?var\\(--border-control\\)`,
|
||||
).test(css),
|
||||
`${selector} 必须使用达到 3:1 的控件边框`,
|
||||
);
|
||||
}
|
||||
report(/@media\s*\(forced-colors:\s*active\)/.test(css), "缺少强制颜色模式适配");
|
||||
|
||||
for (const breakpoint of ["1199px", "959px", "719px"]) {
|
||||
report(css.includes(`max-width: ${breakpoint}`), `缺少 ${breakpoint} 响应式断点`);
|
||||
}
|
||||
|
||||
const requiredCopy = [
|
||||
"免注册",
|
||||
"支持信创软硬件的一站式 AI 助手",
|
||||
"桌面助手",
|
||||
"AI 编程工具台",
|
||||
"Windows、macOS、Linux",
|
||||
@@ -116,11 +270,37 @@ for (const copy of requiredCopy) {
|
||||
report(html.includes(copy), `缺少准确文案:${copy}`);
|
||||
}
|
||||
|
||||
const htmlWithoutSvg = html.replace(/<svg\b[\s\S]*?<\/svg>/g, "");
|
||||
report(
|
||||
!/\bv?\d+\.\d+\.\d+\b/.test(htmlWithoutSvg),
|
||||
"官网正文不得写入需要随发布更新的具体版本号",
|
||||
);
|
||||
const requiredEnglishCopy = [
|
||||
"No account required.",
|
||||
"Your all-in-one",
|
||||
"AI assistant.",
|
||||
"Windows, macOS, and Linux",
|
||||
"Unified Agent Runtime",
|
||||
"Direct models",
|
||||
"OpenCode",
|
||||
"Continue",
|
||||
"DeepSeek Harness",
|
||||
"Download from GitHub",
|
||||
];
|
||||
|
||||
for (const copy of requiredEnglishCopy) {
|
||||
report(englishHtml.includes(copy), `英文页面缺少准确文案:${copy}`);
|
||||
}
|
||||
|
||||
for (const forbiddenCopy of ["信创", "国产", "统信 UOS", "银河麒麟", "海光", "兆芯", "鲲鹏", "飞腾"]) {
|
||||
report(!englishHtml.includes(forbiddenCopy), `英文页面不得包含中文信创文案:${forbiddenCopy}`);
|
||||
}
|
||||
|
||||
for (const [relativePath, content] of [
|
||||
["index.html", html],
|
||||
["en.html", englishHtml],
|
||||
]) {
|
||||
const contentWithoutSvg = content.replace(/<svg\b[\s\S]*?<\/svg>/g, "");
|
||||
report(
|
||||
!/\bv?\d+\.\d+\.\d+\b/.test(contentWithoutSvg),
|
||||
`${relativePath} 正文不得写入需要随发布更新的具体版本号`,
|
||||
);
|
||||
}
|
||||
|
||||
const releaseLinks = [
|
||||
...html.matchAll(/<a\b(?=[^>]*data-release-link)[^>]*>/g),
|
||||
@@ -146,7 +326,34 @@ report(
|
||||
(html.match(/data-download-format/g) ?? []).length === 3,
|
||||
"每个平台必须提供安装包类型选择器",
|
||||
);
|
||||
report(/data-release-status/.test(html), "下载区缺少发布源状态");
|
||||
report(
|
||||
!/data-release-status|download-release-status/.test(`${html}\n${englishHtml}\n${css}\n${appJs}`),
|
||||
"官网不得显示下载源状态提示",
|
||||
);
|
||||
const englishReleaseLinks = [
|
||||
...englishHtml.matchAll(
|
||||
/<a\b(?=[^>]*href="https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/latest")[^>]*>/g,
|
||||
),
|
||||
].map((match) => match[0]);
|
||||
report(englishReleaseLinks.length === 3, "英文页面必须包含三个 GitHub Release 下载入口");
|
||||
for (const link of englishReleaseLinks) {
|
||||
report(/target="_blank"/.test(link), `英文下载入口必须在新窗口打开:${link}`);
|
||||
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `英文下载入口缺少 noreferrer:${link}`);
|
||||
}
|
||||
report(
|
||||
!/data-download-card|data-download-meta|data-release-link/.test(englishHtml),
|
||||
"英文下载入口必须保持为直接 GitHub Release 链接",
|
||||
);
|
||||
report(
|
||||
/<script\s+src="\.\/release-index\.js"><\/script>\s*<script\s+src="\.\/app\.js"><\/script>/.test(
|
||||
html,
|
||||
),
|
||||
"中文页面必须在交互脚本前加载发布索引校验器",
|
||||
);
|
||||
report(
|
||||
!/release-index\.js/.test(englishHtml),
|
||||
"英文页面不得加载动态发布索引校验器",
|
||||
);
|
||||
report(
|
||||
appJs.includes(
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json",
|
||||
@@ -158,52 +365,248 @@ report(
|
||||
"官网必须保留 GitHub Release 回退地址",
|
||||
);
|
||||
report(/credentials:\s*"omit"/.test(appJs), "OSS 发布索引请求不得携带凭据");
|
||||
report(/isTrustedReleaseUrl/.test(appJs), "OSS 下载链接缺少来源校验");
|
||||
|
||||
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||||
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
||||
report(duplicateIds.length === 0, `存在重复 id:${[...new Set(duplicateIds)].join(", ")}`);
|
||||
|
||||
const attributes = [...html.matchAll(/\s(?:href|src)="([^"]+)"/g)].map((match) => match[1]);
|
||||
const fragmentLinks = attributes.filter((value) => value.startsWith("#") && value.length > 1);
|
||||
|
||||
for (const fragment of fragmentLinks) {
|
||||
report(ids.includes(fragment.slice(1)), `页内链接目标不存在:${fragment}`);
|
||||
report(/redirect:\s*"error"/.test(appJs), "OSS 发布索引请求不得跟随重定向");
|
||||
report(/referrerPolicy:\s*"no-referrer"/.test(appJs), "OSS 发布索引请求必须禁用来源信息");
|
||||
report(/maximumIndexBytes/.test(appJs), "OSS 发布索引响应缺少大小上限");
|
||||
report(/response\.body\.getReader\(\)/.test(appJs), "OSS 发布索引响应必须在读取时限制大小");
|
||||
report(/AbortController/.test(appJs), "OSS 发布索引请求必须设置超时取消");
|
||||
report(
|
||||
/validateReleaseIndex\(payload\)/.test(appJs),
|
||||
"动态下载链接必须先通过完整发布索引校验",
|
||||
);
|
||||
report(
|
||||
/const setFallbackDownloads[\s\S]*catch\s*\{[\s\S]*setFallbackDownloads\(\)/.test(
|
||||
appJs,
|
||||
),
|
||||
"发布索引任一错误必须让全部下载入口回退 GitHub",
|
||||
);
|
||||
report(
|
||||
/replaceChildren\(document\.createTextNode\(visibleText\)\)[\s\S]*append\(newWindowNotice\)/.test(
|
||||
appJs,
|
||||
),
|
||||
"动态更新下载链接时必须保留新窗口的屏幕阅读器提示",
|
||||
);
|
||||
report(/if\s*\(!isEnglish\)\s*\{\s*void loadRelease\(\)/.test(appJs), "英文页面不得请求 OSS 发布索引");
|
||||
for (const listenerRule of [
|
||||
'typeof query.addEventListener === "function"',
|
||||
'typeof query.addListener === "function"',
|
||||
"listenMediaQuery(systemTheme",
|
||||
"listenMediaQuery(finePointer",
|
||||
"listenMediaQuery(reducedMotion",
|
||||
"listenMediaQuery(mobileMenu",
|
||||
]) {
|
||||
report(appJs.includes(listenerRule), `媒体查询监听缺少兼容规则:${listenerRule}`);
|
||||
}
|
||||
|
||||
const localAssets = attributes.filter(
|
||||
(value) =>
|
||||
!value.startsWith("#") &&
|
||||
!value.startsWith("https://") &&
|
||||
!value.startsWith("http://") &&
|
||||
!value.startsWith("mailto:") &&
|
||||
!value.startsWith("data:"),
|
||||
for (const menuRule of [
|
||||
"isolatedMenuContent = new Map()",
|
||||
"element === menuBackdrop",
|
||||
"element.inert = true",
|
||||
"element.inert = wasInert",
|
||||
'navigation?.querySelector("a")?.focus()',
|
||||
"closeMenu({ restoreFocus: false })",
|
||||
'menuBackdrop?.addEventListener("click", () => closeMenu())',
|
||||
]) {
|
||||
report(appJs.includes(menuRule), `移动导航隔离或焦点管理缺少规则:${menuRule}`);
|
||||
}
|
||||
report(
|
||||
/const semVerPattern[\s\S]*const sha256Pattern[\s\S]*const targetDefinitions/.test(
|
||||
releaseIndexJs,
|
||||
),
|
||||
"发布索引校验器缺少 SemVer、SHA-256 或目标定义",
|
||||
);
|
||||
for (const rule of [
|
||||
"windows-x64",
|
||||
"windows-arm64",
|
||||
"macos-x64",
|
||||
"macos-arm64",
|
||||
"linux-x64",
|
||||
"linux-arm64",
|
||||
"SHA256SUMS",
|
||||
"encodeURIComponent(file.name)",
|
||||
"!url.username",
|
||||
"!url.password",
|
||||
"!url.port",
|
||||
"!url.search",
|
||||
"!url.hash",
|
||||
"canonicalFileName",
|
||||
"GoodBuddy-${version}-windows-${arch}-setup.exe",
|
||||
"GoodBuddy-${version}-windows-${arch}-portable.zip",
|
||||
"GoodBuddy-${version}-mac-${arch}.${format}",
|
||||
'"x86_64"',
|
||||
'"amd64"',
|
||||
]) {
|
||||
report(releaseIndexJs.includes(rule), `发布索引校验器缺少规则:${rule}`);
|
||||
}
|
||||
report(
|
||||
/navigator\.languages\?\.\[0\]/.test(languageJs),
|
||||
"语言选择必须读取浏览器首选语言",
|
||||
);
|
||||
report(
|
||||
/goodbuddy-site-language/.test(languageJs),
|
||||
"语言选择必须记住用户的手动切换",
|
||||
);
|
||||
report(
|
||||
/window\.location\.replace/.test(languageJs),
|
||||
"语言选择缺少自动页面切换",
|
||||
);
|
||||
report(
|
||||
/targetUrl\.hash\s*=\s*window\.location\.hash/.test(languageJs),
|
||||
"手动切换语言必须保留当前页面片段",
|
||||
);
|
||||
|
||||
for (const asset of localAssets) {
|
||||
const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||
try {
|
||||
const assetStats = await stat(path.join(siteRoot, cleanAsset));
|
||||
report(assetStats.isFile(), `本地资源不是文件:${asset}`);
|
||||
} catch {
|
||||
errors.push(`本地资源不存在:${asset}`);
|
||||
for (const [relativePath, content] of [
|
||||
["index.html", html],
|
||||
["en.html", englishHtml],
|
||||
]) {
|
||||
const menuButton = content.match(
|
||||
/<button\b(?=[^>]*data-menu-toggle)[^>]*>/,
|
||||
)?.[0];
|
||||
const controlsId = menuButton?.match(/aria-controls="([^"]+)"/)?.[1];
|
||||
report(
|
||||
Boolean(controlsId) && content.includes(`id="${controlsId}"`),
|
||||
`${relativePath} 移动导航按钮必须关联现有导航区域`,
|
||||
);
|
||||
report(
|
||||
menuButton?.includes('aria-expanded="false"'),
|
||||
`${relativePath} 移动导航必须声明初始折叠状态`,
|
||||
);
|
||||
report(
|
||||
/<div\s+class="menu-backdrop"\s+aria-hidden="true"\s+data-menu-backdrop><\/div>/.test(
|
||||
content,
|
||||
),
|
||||
`${relativePath} 移动导航缺少页外点击关闭层`,
|
||||
);
|
||||
}
|
||||
report(
|
||||
/@media\s*\(max-width:\s*719px\)[\s\S]*\.menu-toggle\s*\{[\s\S]*display:\s*inline-grid/.test(
|
||||
css,
|
||||
) &&
|
||||
/@media\s*\(max-width:\s*719px\)[\s\S]*\.site-header\.is-menu-open \.site-navigation\s*\{[\s\S]*display:\s*flex/.test(
|
||||
css,
|
||||
) &&
|
||||
/@media\s*\(max-width:\s*719px\)[\s\S]*\.menu-backdrop\.is-active\s*\{[\s\S]*display:\s*block/.test(
|
||||
css,
|
||||
),
|
||||
"移动断点必须显示菜单按钮、页外关闭层并支持展开导航",
|
||||
);
|
||||
|
||||
const expectedDownloadOptions = {
|
||||
windows: {
|
||||
arches: ["x64", "arm64"],
|
||||
formats: ["nsis", "portable"],
|
||||
},
|
||||
macos: {
|
||||
arches: ["arm64", "x64"],
|
||||
formats: ["dmg", "zip"],
|
||||
},
|
||||
linux: {
|
||||
arches: ["x64", "arm64"],
|
||||
formats: ["AppImage", "deb"],
|
||||
},
|
||||
};
|
||||
for (const [platform, expected] of Object.entries(expectedDownloadOptions)) {
|
||||
const card = html.match(
|
||||
new RegExp(
|
||||
`data-download-card="${platform}"([\\s\\S]*?)<\\/article>`,
|
||||
),
|
||||
)?.[1];
|
||||
const archOptions = [
|
||||
...(card ?? "").matchAll(/<option\s+value="([^"]+)"/g),
|
||||
].map((match) => match[1]);
|
||||
report(
|
||||
expected.arches.every((arch, index) => archOptions[index] === arch) &&
|
||||
expected.formats.every(
|
||||
(format, index) => archOptions[index + expected.arches.length] === format,
|
||||
) &&
|
||||
archOptions.length === expected.arches.length + expected.formats.length,
|
||||
`${platform} 下载控件的架构或格式选项无效`,
|
||||
);
|
||||
report(
|
||||
(card?.match(/<select\b[^>]*aria-label="[^"]+"/g) ?? []).length === 2,
|
||||
`${platform} 下载选择器必须有可访问名称`,
|
||||
);
|
||||
}
|
||||
|
||||
let totalIds = 0;
|
||||
let totalLocalAssets = 0;
|
||||
for (const [relativePath, content] of [
|
||||
["index.html", html],
|
||||
["en.html", englishHtml],
|
||||
]) {
|
||||
const ids = [...content.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||||
totalIds += ids.length;
|
||||
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
||||
report(
|
||||
duplicateIds.length === 0,
|
||||
`${relativePath} 存在重复 id:${[...new Set(duplicateIds)].join(", ")}`,
|
||||
);
|
||||
|
||||
const attributes = [...content.matchAll(/\s(?:href|src)="([^"]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
const fragmentLinks = attributes.filter(
|
||||
(value) => value.startsWith("#") && value.length > 1,
|
||||
);
|
||||
for (const fragment of fragmentLinks) {
|
||||
report(ids.includes(fragment.slice(1)), `${relativePath} 页内链接目标不存在:${fragment}`);
|
||||
}
|
||||
|
||||
const localAssets = attributes.filter(
|
||||
(value) =>
|
||||
!value.startsWith("#") &&
|
||||
!value.startsWith("https://") &&
|
||||
!value.startsWith("http://") &&
|
||||
!value.startsWith("mailto:") &&
|
||||
!value.startsWith("data:"),
|
||||
);
|
||||
totalLocalAssets += localAssets.length;
|
||||
for (const asset of localAssets) {
|
||||
const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||
try {
|
||||
const assetStats = await stat(path.join(siteRoot, cleanAsset));
|
||||
report(assetStats.isFile(), `${relativePath} 本地资源不是文件:${asset}`);
|
||||
} catch {
|
||||
errors.push(`${relativePath} 本地资源不存在:${asset}`);
|
||||
}
|
||||
}
|
||||
|
||||
const externalBlankLinks = [
|
||||
...content.matchAll(/<a\b(?=[^>]*target="_blank")[^>]*>/g),
|
||||
].map((match) => match[0]);
|
||||
for (const link of externalBlankLinks) {
|
||||
report(
|
||||
/rel="[^"]*noreferrer[^"]*"/.test(link),
|
||||
`${relativePath} 新窗口链接缺少 noreferrer:${link}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const externalBlankLinks = [
|
||||
...html.matchAll(/<a\b(?=[^>]*target="_blank")[^>]*>/g),
|
||||
].map((match) => match[0]);
|
||||
|
||||
for (const link of externalBlankLinks) {
|
||||
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `新窗口链接缺少 noreferrer:${link}`);
|
||||
const cssAssets = [...css.matchAll(/url\(["']?([^"')]+)["']?\)/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
for (const asset of cssAssets) {
|
||||
if (/^(?:data:|https?:)/u.test(asset)) {
|
||||
continue;
|
||||
}
|
||||
const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||
try {
|
||||
const assetStats = await stat(path.join(siteRoot, cleanAsset));
|
||||
report(assetStats.isFile(), `CSS 本地资源不是文件:${asset}`);
|
||||
} catch {
|
||||
errors.push(`CSS 本地资源不存在:${asset}`);
|
||||
}
|
||||
}
|
||||
|
||||
report(
|
||||
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(html),
|
||||
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(
|
||||
`${html}\n${englishHtml}`,
|
||||
),
|
||||
"具体安装资产链接应由 OSS 发布索引动态提供",
|
||||
);
|
||||
report(
|
||||
!/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test(html),
|
||||
!/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test(
|
||||
`${html}\n${englishHtml}`,
|
||||
),
|
||||
"静态官网不得引入额外框架资源",
|
||||
);
|
||||
|
||||
@@ -215,6 +618,6 @@ if (errors.length > 0) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`官网静态检查通过:${requiredFiles.length} 个必需文件,${ids.length} 个唯一 id,${localAssets.length} 个本地资源引用。`,
|
||||
`官网静态检查通过:${requiredFiles.length} 个必需文件,${totalIds} 个唯一 id,${totalLocalAssets} 个本地资源引用。`,
|
||||
);
|
||||
}
|
||||
|
||||
+132
-29
@@ -1,3 +1,15 @@
|
||||
@font-face {
|
||||
font-family: "Inter Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url("./assets/fonts/inter-latin-variable.woff2") format("woff2-variations");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
|
||||
U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-canvas: #f6f8fb;
|
||||
@@ -8,11 +20,11 @@
|
||||
--surface-overlay: rgba(255, 255, 255, 0.82);
|
||||
--text-primary: #10213a;
|
||||
--text-secondary: #4f6178;
|
||||
--text-muted: #738198;
|
||||
--text-muted: #5b6c82;
|
||||
--text-on-accent: #ffffff;
|
||||
--text-on-inverse: #f7faff;
|
||||
--border-default: #d6dee9;
|
||||
--border-control: #bcc8d7;
|
||||
--border-control: #7c8b9e;
|
||||
--border-subtle: #e6ebf2;
|
||||
--accent: #0877e8;
|
||||
--accent-hover: #0567ca;
|
||||
@@ -59,8 +71,9 @@
|
||||
--z-header: 20;
|
||||
--z-menu: 30;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
"Inter Variable", "PingFang SC", "Microsoft YaHei UI",
|
||||
"Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", system-ui,
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
@@ -327,13 +340,36 @@ p {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.language-link {
|
||||
display: inline-grid;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding-inline: var(--space-2);
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color var(--motion-fast) ease-out,
|
||||
background-color var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.language-link:hover {
|
||||
border-color: var(--accent-selected);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-default);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
@@ -379,6 +415,10 @@ p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
@@ -430,7 +470,7 @@ p {
|
||||
.button--quiet {
|
||||
min-height: 40px;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-color: var(--border-default);
|
||||
border-color: var(--border-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -511,7 +551,7 @@ p {
|
||||
.hero h1 {
|
||||
max-width: 760px;
|
||||
margin-bottom: var(--space-6);
|
||||
font-size: var(--font-page-title);
|
||||
font-size: clamp(2.45rem, 4vw, 3.5rem);
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
@@ -1351,28 +1391,6 @@ p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.download-release-status {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.download-release-status.is-ready {
|
||||
border-color: color-mix(in srgb, var(--success) 34%, var(--border-default));
|
||||
background: var(--success-subtle);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.download-release-status.is-fallback {
|
||||
border-color: color-mix(in srgb, var(--warning) 34%, var(--border-default));
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.domestic-support {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.9fr) minmax(420px, 1.1fr);
|
||||
@@ -1724,6 +1742,8 @@ p {
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
min-height: 64px;
|
||||
}
|
||||
@@ -1763,6 +1783,14 @@ p {
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.menu-backdrop.is-active {
|
||||
position: fixed;
|
||||
z-index: calc(var(--z-header) - 1);
|
||||
inset: 64px 0 0;
|
||||
display: block;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.site-header.is-menu-open .site-navigation {
|
||||
display: flex;
|
||||
}
|
||||
@@ -1972,3 +2000,78 @@ p {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--surface-canvas: Canvas;
|
||||
--surface-raised: Canvas;
|
||||
--surface-subtle: Canvas;
|
||||
--surface-muted: Canvas;
|
||||
--surface-inverse: Canvas;
|
||||
--surface-overlay: Canvas;
|
||||
--text-primary: CanvasText;
|
||||
--text-secondary: CanvasText;
|
||||
--text-muted: CanvasText;
|
||||
--text-on-accent: HighlightText;
|
||||
--text-on-inverse: CanvasText;
|
||||
--border-default: ButtonBorder;
|
||||
--border-control: ButtonText;
|
||||
--border-subtle: ButtonBorder;
|
||||
--accent: LinkText;
|
||||
--accent-hover: LinkText;
|
||||
--accent-solid: Highlight;
|
||||
--accent-solid-hover: Highlight;
|
||||
--accent-selected: Highlight;
|
||||
--accent-subtle: Canvas;
|
||||
--accent-cyan: LinkText;
|
||||
--accent-mint: LinkText;
|
||||
--success: CanvasText;
|
||||
--success-subtle: Canvas;
|
||||
--warning: CanvasText;
|
||||
--danger: CanvasText;
|
||||
--shadow-card: none;
|
||||
--shadow-dialog: none;
|
||||
--shadow-button: none;
|
||||
}
|
||||
|
||||
.site-header.is-scrolled,
|
||||
.site-header.is-menu-open,
|
||||
.site-navigation,
|
||||
.language-link,
|
||||
.icon-button,
|
||||
.button,
|
||||
.app-window,
|
||||
.floating-card,
|
||||
.feature-card,
|
||||
.download-card,
|
||||
.download-options select,
|
||||
.domestic-support {
|
||||
border-color: ButtonBorder;
|
||||
}
|
||||
|
||||
.site-navigation a[aria-current="true"],
|
||||
.button--primary,
|
||||
.message--user,
|
||||
.composer-actions b {
|
||||
background: Highlight;
|
||||
color: HighlightText;
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
|
||||
.hero h1 span {
|
||||
background: none;
|
||||
color: LinkText;
|
||||
}
|
||||
|
||||
.stage-glow,
|
||||
.hero::before,
|
||||
.product-stage::before,
|
||||
.app-window::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline-color: Highlight;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user