diff --git a/CHANGELOG.md b/CHANGELOG.md index 5534e2c5..9f82faac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-05 — 发布构建不再从网上拉 Inter + +西文正文字体改成仓库里的 Inter 文件,镜像构建不再访问 Google Fonts。页面上看起来还是同一套字。Skill 版本未变。 + ## 2026-09-04 — 入学毕业不再连问两道相同的上大学发挥题 候选区分阶段,同一套发挥选项只问一次。入学和毕业如果切分相同,只问入学那年;毕业不再套「调剂 / 发挥失常」这道题。Skill 版本仍是 10.0.14。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7099fad1..70498524 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6578,15 +6578,15 @@ - 状态:resolved - 首次发现:2026-08-29 -- 最近更新:2026-08-29 +- 最近更新:2026-09-05 - 影响面:全站 `--font-body`、后台 antd `fontFamily` - 用户现象:设计稿写了 Inter,实际请求里没有 woff2;非苹果系统落到 Segoe UI / 微软雅黑。 - 触发条件:打开任意带根布局的页面。 - 根因:仓库 0 个字体文件、0 处 `@font-face`、0 处 `next/font`,CSS 与 admin token 却把 Inter 写在 StyreneB 之后。 -- 修复:根布局用 `next/font/google` 加载 Inter(`display: "swap"`、`--font-inter`)。`--font-body` 与 admin `fontFamily` 改为 `var(--font-inter, Inter)`。Tiempos / 宋体栈未改。构建产物与 `/` HTML 可见 woff2 preload。 +- 修复:根布局用 `next/font/local` 加载仓内 `InterVariable-latin.woff2`(`display: "swap"`、`--font-inter`)。早期曾用 `next/font/google`,构建期会访问 Google Fonts,见 BUG-543。`--font-body` 与 admin `fontFamily` 改为 `var(--font-inter, Inter)`。Tiempos / 宋体栈未改。构建产物与 `/` HTML 可见 woff2 preload。 - 验证:`frontend/tests/site-style-isolation-contract.test.ts`;`next build` 的 `/` HTML 含 Inter woff2 preload。 -- 防复发:字体栈里出现的西文家族必须有 `next/font` 或 `@font-face`;缺授权的展示字体(Tiempos)不得用同一套办法偷偷补上。 -- 相关记录:无 +- 防复发:字体栈里出现的西文家族必须有 `next/font/local`(仓内字体文件)或 `@font-face`;不得用 `next/font/google`。缺授权的展示字体(Tiempos)不得用同一套办法偷偷补上。 +- 相关记录:BUG-543 - 复发自:无 - 修复版本:待发布 @@ -8372,3 +8372,20 @@ - 相关记录:BUG-390、BUG-540 - 复发自:BUG-390(质量探针只对学业发出,但学业内 kind 未收紧) - 修复版本:待发布 + +## BUG-543 | staging 镜像构建因 Google Fonts 拉不到 Inter 失败 + +- 状态:resolved +- 首次发现:2026-09-05 +- 最近更新:2026-09-05 +- 影响面:Gitea `backend-quality-gate.yml` `publish`、`deploy/railway-web.Dockerfile` 的 `RUN npm run build`、`frontend/src/app/layout.tsx` +- 用户现象:无终端用户可见现象。门禁 validate 通过后 publish 失败,staging 不发布新镜像。 +- 触发条件:推送触及门禁路径后,publish 在 Docker 内执行 `npm run build`。构建环境访问不到 `fonts.googleapis.com`。 +- 根因:BUG-430 用 `next/font/google` 在构建期下载 Inter。validate 跑在 hostexecutor,能出网;publish 的镜像构建不能。Gitea run 2408(SHA `40c623ed`)报 `Failed to fetch Inter from Google Fonts`,`railway-web.Dockerfile:26` 退出 1。 +- 修复:把 latin 可变 Inter(OFL)放进 `frontend/src/app/fonts/InterVariable-latin.woff2`,根布局改 `next/font/local`。不改 workflow、不设构建期代理。字体栈与 `--font-inter` 不变。 +- 验证:`site-style-isolation-contract` 锁定 `next/font/local`、仓内 woff2 魔数、以及 `frontend/src` 不再出现 `next/font/google` / `fonts.googleapis.com`。本机 `npx tsx --test tests/site-style-isolation-contract.test.ts`;`tsc --noEmit`。 +- 防复发:不得把西文正文字体改回 `next/font/google`。新字体必须是仓内文件 + `next/font/local` 或 `@font-face`。 +- 相关记录:BUG-430 +- 复发自:BUG-430(加载方式在无 Google Fonts 网络的镜像构建里不成立) +- 修复版本:待发布 + diff --git a/docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md b/docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md index baba9d10..8fee4ce5 100644 --- a/docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md +++ b/docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md @@ -42,3 +42,13 @@ | `run_quality_gate.py --profile quick` 内 `npm test` | 2682 tests / 2673 pass / **9 fail**,全部是 Docker 迁移 `database migration failed`(`database-foundation` / `database-local-business` / redeem / identity / secret-refs / v9×3 / Skill migration)。与本单无关,属既有环境缺口。 | 未跑全量 `npm test`(时间);未做浏览器真人走查(无登录态)。清单:`docs/testing/rectification-quality-probe-dedupe-20260904.md`。 + +## 门禁修复(BUG-543) + +Gitea run 2408:`validate` 通过,`publish` 在 `railway-web.Dockerfile` `RUN npm run build` 失败。报错 `Failed to fetch Inter from Google Fonts`。根因是 BUG-430 的 `next/font/google` 在镜像构建里访问不到 Google。未改 workflow。 + +| 项 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `site-style-isolation-contract` Inter 加载断言 | `import { Inter } from "next/font/google"` | `import localFont from "next/font/local"` + 仓内 woff2 魔数 + `frontend/src` 禁止 `from "next/font/google"` / `fonts.googleapis.com` | 构建期不得出网拉字体 | + +本机补充:`npx tsx --test tests/site-style-isolation-contract.test.ts` **4/4**;`tsc --noEmit` 0 错;改动文件 eslint 0 error。本机 `npm run build` 被 worktree 里 `frontend/node_modules` 指向仓外的 symlink 挡住(Turbopack `points out of the filesystem root`),属既有环境缺口;CI publish 镜像内是 `npm ci`,无此 symlink。编号:BUG-543(未占用预留的 BUG-542)。未改 `.gitea/workflows/**`。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 51ab23b6..51eb000a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -136,7 +136,7 @@ is read through an external store so a change in one tab reaches the others. ### Font stacks - Display: `"Tiempos Headline", "Songti SC", "STSong", "Noto Serif CJK SC", Georgia, serif`. The licensed Copernicus/Tiempos files are unavailable; the Chinese Song serif stack is the declared production substitute. -- Body/UI: `StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/google` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute. +- Body/UI: `StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/local` from `src/app/fonts/InterVariable-latin.woff2` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter, and image builds do not call fonts.googleapis.com. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute. - Code/data: `"JetBrains Mono", "SFMono-Regular", Consolas, monospace`, exposed as `--font-mono`. - Root boundary pages (`error.tsx`, `not-found.tsx`, `forbidden.tsx`, `global-error.tsx`) sit in the shared root layout segment and must not import `globals.css` — importing it would drag the chat stylesheet onto every admin route. They therefore cannot read `--font-mono` or any token, and inline their own values: a system stack for body text and `ui-monospace, SFMono-Regular, Menlo, monospace` for code. Keep those literals in step with the stacks above by hand. diff --git a/frontend/src/app/fonts/InterVariable-latin.woff2 b/frontend/src/app/fonts/InterVariable-latin.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/frontend/src/app/fonts/InterVariable-latin.woff2 differ diff --git a/frontend/src/app/fonts/OFL.txt b/frontend/src/app/fonts/OFL.txt new file mode 100644 index 00000000..40589daa --- /dev/null +++ b/frontend/src/app/fonts/OFL.txt @@ -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. diff --git a/frontend/src/app/fonts/SOURCE.txt b/frontend/src/app/fonts/SOURCE.txt new file mode 100644 index 00000000..9c8a13df --- /dev/null +++ b/frontend/src/app/fonts/SOURCE.txt @@ -0,0 +1,5 @@ +Inter latin variable (wght axis) +File: InterVariable-latin.woff2 +Taken from @fontsource-variable/inter@5.3.0 files/inter-latin-wght-normal.woff2 +Upstream: https://github.com/rsms/inter +License: SIL Open Font License 1.1 (OFL.txt) diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 61da1139..a0a98de7 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,14 +1,17 @@ import type { Metadata, Viewport } from "next"; -import { Inter } from "next/font/google"; +import localFont from "next/font/local"; import Script from "next/script"; import { Toaster } from "@/components/ui/sonner"; import { StaleClientRecovery } from "@/components/stale-client-recovery"; import { themePreferenceBootScript } from "@/lib/theme-preference"; -const inter = Inter({ - subsets: ["latin"], +// Vendored Inter latin variable (OFL). Do not switch back to next/font/google; +// the publish image cannot download fonts at build time. +const inter = localFont({ + src: "./fonts/InterVariable-latin.woff2", display: "swap", variable: "--font-inter", + weight: "100 900", }); export const metadata: Metadata = { diff --git a/frontend/tests/site-style-isolation-contract.test.ts b/frontend/tests/site-style-isolation-contract.test.ts index 446b0e44..3905d78a 100644 --- a/frontend/tests/site-style-isolation-contract.test.ts +++ b/frontend/tests/site-style-isolation-contract.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; +import { extname, join } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); const siteStyles = readFileSync(new URL("../src/app/site-styles.ts", import.meta.url), "utf8"); @@ -10,7 +12,9 @@ const homePage = readFileSync(new URL("../src/app/page.tsx", import.meta.url), " const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8"); test("Inter is loaded through next/font and applied as a CSS variable", () => { - assert.match(layout, /import \{ Inter \} from "next\/font\/google"/); + assert.match(layout, /import localFont from "next\/font\/local"/); + assert.doesNotMatch(layout, /from ["']next\/font\/google["']/); + assert.match(layout, /src: "\.\/fonts\/InterVariable-latin\.woff2"/); assert.match(layout, /display: "swap"/); assert.match(layout, /variable: "--font-inter"/); assert.match(layout, /className=\{inter\.variable\}/); @@ -21,6 +25,31 @@ test("Inter is loaded through next/font and applied as a CSS variable", () => { ); }); +test("Inter is vendored locally so production builds do not fetch Google Fonts", () => { + const font = readFileSync(new URL("../src/app/fonts/InterVariable-latin.woff2", import.meta.url)); + assert.equal(Buffer.from(font.subarray(0, 4)).toString(), "wOF2"); + assert.ok(font.byteLength > 20_000); + const srcRoot = fileURLToPath(new URL("../src/", import.meta.url)); + const stack = [srcRoot]; + while (stack.length > 0) { + const dir = stack.pop()!; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (![".ts", ".tsx", ".js", ".jsx", ".css"].includes(extname(entry.name))) continue; + const source = readFileSync(fullPath, "utf8"); + assert.doesNotMatch( + source, + /from ["']next\/font\/google["']|fonts\.googleapis\.com/, + `${fullPath} must not fetch Google Fonts at build time`, + ); + } + } +}); + test("site chrome owns globals.css; admin does not import it", () => { assert.match(siteStyles, /import "\.\/globals\.css"/); assert.match(siteStyles, /import "\.\/birth-time-choice\.css"/);