diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..59913c2
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+# Preserve byte-addressed assets; recognize CRLF while retaining whitespace checks.
+assets/fastui/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
+# The generated bootstrap records this adapter's source-byte digest.
+scripts/theme-init.js -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
+# Preserve the complete generated account page used by delivery byte audits.
+assets/account.html -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml
index 00dd3a6..2141e1c 100644
--- a/.github/workflows/tag-ci.yml
+++ b/.github/workflows/tag-ci.yml
@@ -49,7 +49,7 @@ jobs:
deploy:
needs: validate
- if: startsWith(github.ref, 'refs/tags/v')
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2
with:
app: fastnews
diff --git a/assets/account.html b/assets/account.html
index 00fa556..694e5c3 100644
--- a/assets/account.html
+++ b/assets/account.html
@@ -1,27 +1,282 @@
-
FastNews · 账号
-
-← 返回报告登录 FastNews
使用本项目账号登录。邀请注册和账号恢复凭证由实例管理员提供。
-
-
-
-
-
+FastNews · 账号
+
+
+
+
+← 返回报告登录 FastNews
使用本项目账号登录。邀请注册和账号恢复凭证由实例管理员提供。
+
+
+
+
+
diff --git a/assets/authors-nav.js b/assets/authors-nav.js
index 2a40789..ddd7724 100644
--- a/assets/authors-nav.js
+++ b/assets/authors-nav.js
@@ -39,7 +39,7 @@
function applySidebar(collapsed) {
document.documentElement.classList.toggle("sidebar-collapsed", collapsed);
- window.localStorage.setItem("fastnews.sidebarCollapsed", collapsed ? "1" : "0");
+ try { window.localStorage.setItem("fastnews.sidebarCollapsed", collapsed ? "1" : "0"); } catch { /* optional preference storage */ }
if (sidebarToggle) {
sidebarToggle.title = collapsed ? "打开总览栏" : "隐藏总览栏";
sidebarToggle.setAttribute("aria-label", sidebarToggle.title);
@@ -59,7 +59,7 @@
applySidebar(document.documentElement.classList.contains("sidebar-collapsed"));
}
- fetch("/api/content/me", { credentials: "include" })
+ fetch(window.fastNewsApi("/api/content/me"), { credentials: "include" })
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (!payload) return;
@@ -77,7 +77,7 @@
})
.catch(() => {});
- fetch("/api/content/inbox", { credentials: "include" })
+ fetch(window.fastNewsApi("/api/content/inbox"), { credentials: "include" })
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (!payload) return;
diff --git a/assets/authors.js b/assets/authors.js
index 510ef20..8343025 100644
--- a/assets/authors.js
+++ b/assets/authors.js
@@ -101,7 +101,7 @@
async function persistRemote() {
if (!researchSession) return;
try {
- await fetch("/api/content/authors", {
+ await fetch(window.fastNewsApi("/api/content/authors"), {
method: "PUT",
credentials: "include",
headers: {
@@ -145,14 +145,14 @@
}
async function fetchCookieSession() {
- const response = await fetch("/api/content/me", { credentials: "include" });
+ const response = await fetch(window.fastNewsApi("/api/content/me"), { credentials: "include" });
const payload = await response.json().catch(() => ({}));
if (!response.ok || !(payload.keyId || payload.userId)) return null;
return sessionFromPayload("", payload);
}
async function fetchRemoteAuthors() {
- const response = await fetch("/api/content/authors", {
+ const response = await fetch(window.fastNewsApi("/api/content/authors"), {
credentials: "include",
headers: researchSession.session ? { Authorization: `Bearer ${researchSession.session}` } : {},
});
diff --git a/assets/fastui/LICENSE b/assets/fastui/LICENSE
new file mode 100644
index 0000000..47c4d66
--- /dev/null
+++ b/assets/fastui/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 FastR-D contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/assets/fastui/NOTICE.md b/assets/fastui/NOTICE.md
new file mode 100644
index 0000000..59d1ef6
--- /dev/null
+++ b/assets/fastui/NOTICE.md
@@ -0,0 +1,23 @@
+# Source and publication scope
+
+The owner has authorized publication of the generic UI portions under MIT.
+Vue semantic primitives and v-model adapters derive from the common
+`primitives.ts` (203 lines) in FastPPT and FastFigure. Modal's public props and
+the focus-lifecycle purpose derive from their common `use-dialog-focus.ts`
+(58 lines) and Modal component. FastUI delegates focus trapping, inertness,
+nesting and focus restoration to the browser's native modal dialog rather
+than maintaining the original hand-written trap. Select retains the shared
+value/options/change contract and delegates interaction to the native select.
+
+The palette follows FastWrite's semantic colors and the approved FastTask
+workbench design. Only generic UI code and semantic design values are included.
+No private artwork, icons, logos, fonts, application data, product-specific
+workflows or brand assets are distributed. Upstream product repositories
+retain ownership of their application-specific code and assets.
+
+Source reference revisions: FastPPT `f6bf21f13445c2507719928fa68550552a8fcab2`
+(`packages/components/src/`) and FastFigure
+`13dcc4179fd0073da76f7626264af8605d5e8c70` (`src/components/ui/`).
+At both revisions, `primitives.ts` has Git blob
+`c7123af5f8316bc0486bce5c99cfbb2e9ab3dc73` and `use-dialog-focus.ts` has
+Git blob `3ffbb6c6c7bd5e3c96f2b8c05c5e008dcdfc4cab`.
diff --git a/assets/fastui/color-mode.js b/assets/fastui/color-mode.js
new file mode 100644
index 0000000..02a627b
--- /dev/null
+++ b/assets/fastui/color-mode.js
@@ -0,0 +1,75 @@
+"use strict";
+(() => {
+ // packages/foundation/src/index.ts
+ var parse = (value) => value === "light" || value === "dark" ? value : "system";
+ function initColorMode({ storageKey: storageKey2, target = document.documentElement }) {
+ const win = target.ownerDocument.defaultView;
+ const media = win.matchMedia("(prefers-color-scheme: dark)");
+ let mode = "system";
+ try {
+ mode = parse(win.localStorage.getItem(storageKey2));
+ } catch {
+ }
+ let disposed = false;
+ const listeners = /* @__PURE__ */ new Set();
+ const resolved = () => mode === "system" ? media.matches ? "dark" : "light" : mode;
+ const apply = () => {
+ target.dataset.fastuiTheme = resolved();
+ listeners.forEach((listener) => listener({ mode, resolved: resolved() }));
+ };
+ const systemChange = () => {
+ if (mode === "system") apply();
+ };
+ const storageChange = (event) => {
+ let storage;
+ try {
+ storage = win.localStorage;
+ } catch {
+ return;
+ }
+ if (event.storageArea !== storage || event.key !== storageKey2 && event.key !== null) return;
+ mode = parse(event.newValue);
+ apply();
+ };
+ media.addEventListener("change", systemChange);
+ win.addEventListener("storage", storageChange);
+ apply();
+ return {
+ get mode() {
+ return mode;
+ },
+ get resolved() {
+ return resolved();
+ },
+ setMode(value) {
+ if (disposed) return;
+ mode = parse(value);
+ try {
+ win.localStorage.setItem(storageKey2, mode);
+ } catch {
+ }
+ apply();
+ },
+ subscribe(listener) {
+ if (disposed) return () => {
+ };
+ listeners.add(listener);
+ listener({ mode, resolved: resolved() });
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ dispose() {
+ disposed = true;
+ media.removeEventListener("change", systemChange);
+ win.removeEventListener("storage", storageChange);
+ listeners.clear();
+ }
+ };
+ }
+
+ // packages/foundation/src/bootstrap.ts
+ var storageKey = document.currentScript.dataset.storageKey;
+ if (!storageKey) throw new Error("FastUI color-mode.js requires data-storage-key on its script element.");
+ initColorMode({ storageKey });
+})();
diff --git a/assets/fastui/controls.css b/assets/fastui/controls.css
new file mode 100644
index 0000000..fd85a23
--- /dev/null
+++ b/assets/fastui/controls.css
@@ -0,0 +1,61 @@
+/* Every appearance rule is explicitly opted into by a fastui class. No reset. */
+.fastui-button, .fastui-input, .fastui-textarea, .fastui-select {
+ box-sizing: border-box;
+ font-family: var(--fastui-font);
+ font-size: 13px;
+ line-height: 1.5;
+ min-height: var(--fastui-height-md);
+ padding: 7px 10px;
+ border: 1px solid var(--fastui-border-strong);
+ border-radius: var(--fastui-radius-control);
+ background: var(--fastui-surface);
+ color: var(--fastui-text);
+ max-width: 100%;
+ min-width: 0;
+ color-scheme: var(--fastui-color-scheme);
+}
+.fastui-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer; font-weight: 500; text-decoration: none; }
+.fastui-button--sm, .fastui-input--sm, .fastui-select--sm { min-height: var(--fastui-height-sm); padding: 4px 8px; font-size: 12px; }
+.fastui-button--xs, .fastui-input--xs, .fastui-select--xs { min-height: var(--fastui-height-xs); padding: 1px 6px; font-size: 12px; }
+.fastui-button:hover:not(:disabled) { background: var(--fastui-selected); }
+.fastui-button--primary { background: var(--fastui-primary); border-color: var(--fastui-primary); color: var(--fastui-on-primary); }
+.fastui-button--primary:hover:not(:disabled) { background: var(--fastui-primary-hover); }
+.fastui-button--danger { background: var(--fastui-danger-fill); border-color: var(--fastui-danger-fill); color: var(--fastui-on-primary); }
+.fastui-button--danger:hover:not(:disabled) { filter: brightness(.9); background: var(--fastui-danger-fill); }
+.fastui-button--quiet, .fastui-button--icon, .fastui-button--navigation { background: transparent; border-color: transparent; }
+.fastui-button--icon { aspect-ratio: 1; padding: 5px; }
+.fastui-button--navigation { border-radius: 0; }
+.fastui-button[aria-pressed=true], .fastui-button[aria-selected=true] { box-shadow: inset 0 -2px var(--fastui-focus); }
+.fastui-button:disabled, .fastui-input:disabled, .fastui-textarea:disabled, .fastui-select:disabled { opacity: .6; cursor: not-allowed; }
+.fastui-button:focus-visible, .fastui-input:focus-visible, .fastui-textarea:focus-visible, .fastui-select:focus-visible, .fastui-link:focus-visible, .fastui-disclosure-trigger:focus-visible { outline: 2px solid var(--fastui-focus); outline-offset: 2px; }
+.fastui-input::placeholder, .fastui-textarea::placeholder { color: var(--fastui-muted); }
+.fastui-input[aria-invalid=true], .fastui-textarea[aria-invalid=true] { border-color: var(--fastui-danger); }
+.fastui-input[type=checkbox], .fastui-input[type=radio], .fastui-input[type=range] { min-height: auto; padding: 0; accent-color: var(--fastui-primary); }
+.fastui-textarea { resize: vertical; }
+.fastui-field { display: grid; gap: 6px; color: var(--fastui-secondary); font: 13px/1.5 var(--fastui-font); }
+.fastui-fieldset { border: 1px solid var(--fastui-border); border-radius: var(--fastui-radius-panel); min-width: 0; padding: 16px; }
+.fastui-legend { color: var(--fastui-secondary); padding: 0 4px; }
+.fastui-heading { color: var(--fastui-text); font-weight: 600; }
+.fastui-link { color: var(--fastui-focus); text-underline-offset: 3px; }
+.fastui-progress { accent-color: var(--fastui-primary); }
+.fastui-table { border-collapse: collapse; text-align: left; }
+.fastui-table :where(th, td) { padding: 12px; border-bottom: 1px solid var(--fastui-border); }
+.fastui-table th { background: var(--fastui-raised); font-weight: 600; }
+.fastui-disclosure-trigger { padding: 5px 8px; cursor: pointer; }
+.fastui-dialog { box-sizing: border-box; width: min(620px, calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow: auto; padding: 0; border: 1px solid var(--fastui-border); border-radius: var(--fastui-radius-panel); background: var(--fastui-surface); color: var(--fastui-text); font: 14px/1.5 var(--fastui-font); box-shadow: 0 16px 48px #0004; }
+.fastui-dialog--lg { width: min(880px, calc(100% - 32px)); }
+.fastui-dialog--xl { width: min(1100px, calc(100% - 32px)); }
+.fastui-dialog::backdrop { background: var(--fastui-overlay); }
+.fastui-dialog > :where(header, footer), .fastui-modal-body { padding: 20px 24px; }
+.fastui-dialog > header { border-bottom: 1px solid var(--fastui-border); }
+.fastui-dialog > footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--fastui-border); }
+.fastui-modal-body { display: grid; gap: 16px; }
+.fastui-alert, .fastui-badge, .fastui-empty, .fastui-loading { font-family: var(--fastui-font); color: var(--fastui-text); }
+.fastui-alert { border: 1px solid var(--fastui-border); border-left: 3px solid var(--fastui-tone, var(--fastui-focus)); border-radius: var(--fastui-radius-control); padding: 12px 16px; background: var(--fastui-surface); }
+.fastui-badge { display: inline-block; border: 1px solid currentColor; color: var(--fastui-tone, var(--fastui-focus)); border-radius: 4px; padding: 1px 6px; font-size: 12px; }
+.fastui-tone-success { --fastui-tone: var(--fastui-success); }
+.fastui-tone-warning { --fastui-tone: var(--fastui-warning); }
+.fastui-tone-danger { --fastui-tone: var(--fastui-danger); }
+.fastui-empty { padding: 24px; text-align: center; }
+.fastui-empty > h2 { font-size: 16px; }
+.fastui-loading { display: inline-flex; align-items: center; gap: 8px; }
diff --git a/assets/fastui/index.js b/assets/fastui/index.js
new file mode 100644
index 0000000..e1ae2b4
--- /dev/null
+++ b/assets/fastui/index.js
@@ -0,0 +1,79 @@
+// packages/foundation/src/index.ts
+var parse = (value) => value === "light" || value === "dark" ? value : "system";
+function initColorMode({ storageKey, target = document.documentElement }) {
+ const win = target.ownerDocument.defaultView;
+ const media = win.matchMedia("(prefers-color-scheme: dark)");
+ let mode = "system";
+ try {
+ mode = parse(win.localStorage.getItem(storageKey));
+ } catch {
+ }
+ let disposed = false;
+ const listeners = /* @__PURE__ */ new Set();
+ const resolved = () => mode === "system" ? media.matches ? "dark" : "light" : mode;
+ const apply = () => {
+ target.dataset.fastuiTheme = resolved();
+ listeners.forEach((listener) => listener({ mode, resolved: resolved() }));
+ };
+ const systemChange = () => {
+ if (mode === "system") apply();
+ };
+ const storageChange = (event) => {
+ let storage;
+ try {
+ storage = win.localStorage;
+ } catch {
+ return;
+ }
+ if (event.storageArea !== storage || event.key !== storageKey && event.key !== null) return;
+ mode = parse(event.newValue);
+ apply();
+ };
+ media.addEventListener("change", systemChange);
+ win.addEventListener("storage", storageChange);
+ apply();
+ return {
+ get mode() {
+ return mode;
+ },
+ get resolved() {
+ return resolved();
+ },
+ setMode(value) {
+ if (disposed) return;
+ mode = parse(value);
+ try {
+ win.localStorage.setItem(storageKey, mode);
+ } catch {
+ }
+ apply();
+ },
+ subscribe(listener) {
+ if (disposed) return () => {
+ };
+ listeners.add(listener);
+ listener({ mode, resolved: resolved() });
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ dispose() {
+ disposed = true;
+ media.removeEventListener("change", systemChange);
+ win.removeEventListener("storage", storageChange);
+ listeners.clear();
+ }
+ };
+}
+function syncDialog(element, open) {
+ if (open && !element.open) {
+ const descendants = Array.from(element.querySelectorAll("dialog:modal"));
+ for (const child of [...descendants].reverse()) child.close();
+ element.showModal();
+ for (const child of descendants) child.showModal();
+ } else if (!open && element.open) element.close();
+}
+export {
+ initColorMode,
+ syncDialog
+};
diff --git a/assets/fastui/provenance.json b/assets/fastui/provenance.json
new file mode 100644
index 0000000..9662720
--- /dev/null
+++ b/assets/fastui/provenance.json
@@ -0,0 +1,21 @@
+{
+ "package": "@fastrd/ui-foundation",
+ "version": "0.1.2",
+ "url": "https://registry.npmjs.org/@fastrd/ui-foundation/-/ui-foundation-0.1.2.tgz",
+ "sha256": "c3dabbad16980242f66db07619b161889d0ee2492daaa4aaddee1583bc7bcd0b",
+ "files": {
+ "tokens.css": "fadf2cf99aa54150ff65776032c4f61b007ebe47a0051723f81afac0b397de38",
+ "controls.css": "f34fa4d48fbae9dcbf22fc7fcb1718e2b1a5cb33c36bc287805fd49b61cecbc4",
+ "index.js": "5085eb867b23e1531bd512bd55f1ba87da551f74bc2008e38a45ab748940c201",
+ "color-mode.js": "596e123cf27bf280dec4cabafe98dfbcf780fbef546763c8c27a0c28e6c52f99"
+ },
+ "generated_by": "python scripts/supply_fastui.py",
+ "generated": {
+ "theme-init.js": {
+ "sha256": "b060ada12d03ff1940c0deceff35a07bf6312874fba1d90a49063e7ded71a5d7",
+ "foundation_source": "index.js",
+ "adapter": "scripts/theme-init.js",
+ "adapter_sha256": "978165ab783b8b5f127fa1c73fe2ca2e2fe02e617210465b1ef5c36ef13ef53f"
+ }
+ }
+}
diff --git a/assets/fastui/theme-init.js b/assets/fastui/theme-init.js
new file mode 100644
index 0000000..70c20d5
--- /dev/null
+++ b/assets/fastui/theme-init.js
@@ -0,0 +1,93 @@
+// Generated from @fastrd/ui-foundation 0.1.2; do not edit.
+(() => {
+// packages/foundation/src/index.ts
+var parse = (value) => value === "light" || value === "dark" ? value : "system";
+function initColorMode({ storageKey, target = document.documentElement }) {
+ const win = target.ownerDocument.defaultView;
+ const media = win.matchMedia("(prefers-color-scheme: dark)");
+ let mode = "system";
+ try {
+ mode = parse(win.localStorage.getItem(storageKey));
+ } catch {
+ }
+ let disposed = false;
+ const listeners = /* @__PURE__ */ new Set();
+ const resolved = () => mode === "system" ? media.matches ? "dark" : "light" : mode;
+ const apply = () => {
+ target.dataset.fastuiTheme = resolved();
+ listeners.forEach((listener) => listener({ mode, resolved: resolved() }));
+ };
+ const systemChange = () => {
+ if (mode === "system") apply();
+ };
+ const storageChange = (event) => {
+ let storage;
+ try {
+ storage = win.localStorage;
+ } catch {
+ return;
+ }
+ if (event.storageArea !== storage || event.key !== storageKey && event.key !== null) return;
+ mode = parse(event.newValue);
+ apply();
+ };
+ media.addEventListener("change", systemChange);
+ win.addEventListener("storage", storageChange);
+ apply();
+ return {
+ get mode() {
+ return mode;
+ },
+ get resolved() {
+ return resolved();
+ },
+ setMode(value) {
+ if (disposed) return;
+ mode = parse(value);
+ try {
+ win.localStorage.setItem(storageKey, mode);
+ } catch {
+ }
+ apply();
+ },
+ subscribe(listener) {
+ if (disposed) return () => {
+ };
+ listeners.add(listener);
+ listener({ mode, resolved: resolved() });
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ dispose() {
+ disposed = true;
+ media.removeEventListener("change", systemChange);
+ win.removeEventListener("storage", storageChange);
+ listeners.clear();
+ }
+ };
+}
+function syncDialog(element, open) {
+ if (open && !element.open) {
+ const descendants = Array.from(element.querySelectorAll("dialog:modal"));
+ for (const child of [...descendants].reverse()) child.close();
+ element.showModal();
+ for (const child of descendants) child.showModal();
+ } else if (!open && element.open) element.close();
+}
+
+// This adapter is bundled after the verified foundation ESM by supply_fastui.py.
+// The resulting classic script runs before gate.js and before page content.
+try {
+ if (localStorage.getItem('fastresearch-theme') === null) {
+ const old = localStorage.getItem('fastnews.theme');
+ if (old === 'light' || old === 'dark') localStorage.setItem('fastresearch-theme', old);
+ }
+} catch { /* Preference storage is optional. */ }
+const colorMode = initColorMode({ storageKey: 'fastresearch-theme' });
+colorMode.subscribe(({ resolved }) => {
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
+});
+window.fastNewsColorMode = colorMode;
+
+})();
diff --git a/assets/fastui/tokens.css b/assets/fastui/tokens.css
new file mode 100644
index 0000000..7008b75
--- /dev/null
+++ b/assets/fastui/tokens.css
@@ -0,0 +1,98 @@
+/* Generated from tokens.json. */
+:root {
+ --fastui-font: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans CJK SC', sans-serif;
+ --fastui-font-size: 14px;
+ --fastui-radius-control: 6px;
+ --fastui-radius-panel: 8px;
+ --fastui-space-1: 4px;
+ --fastui-space-2: 8px;
+ --fastui-space-3: 12px;
+ --fastui-space-4: 16px;
+ --fastui-space-6: 24px;
+ --fastui-space-8: 32px;
+ --fastui-height-xs: 24px;
+ --fastui-height-sm: 30px;
+ --fastui-height-md: 38px;
+ --fastui-on-primary: #ffffff;
+ --fastui-on-primary-rgb: 255, 255, 255;
+}
+:root, [data-fastui-theme="light"] {
+ --fastui-page: #f7f8fa;
+ --fastui-page-rgb: 247, 248, 250;
+ --fastui-workspace: #eef0f3;
+ --fastui-workspace-rgb: 238, 240, 243;
+ --fastui-surface: #ffffff;
+ --fastui-surface-rgb: 255, 255, 255;
+ --fastui-raised: #eef0f3;
+ --fastui-raised-rgb: 238, 240, 243;
+ --fastui-text: #151c28;
+ --fastui-text-rgb: 21, 28, 40;
+ --fastui-secondary: #4b5565;
+ --fastui-secondary-rgb: 75, 85, 101;
+ --fastui-muted: #687384;
+ --fastui-muted-rgb: 104, 115, 132;
+ --fastui-border: #dfe3e8;
+ --fastui-border-rgb: 223, 227, 232;
+ --fastui-border-strong: #a7b0bd;
+ --fastui-border-strong-rgb: 167, 176, 189;
+ --fastui-primary: #155fb8;
+ --fastui-primary-rgb: 21, 95, 184;
+ --fastui-primary-hover: #104a91;
+ --fastui-primary-hover-rgb: 16, 74, 145;
+ --fastui-focus: #155fb8;
+ --fastui-focus-rgb: 21, 95, 184;
+ --fastui-selected: #e5effb;
+ --fastui-selected-rgb: 229, 239, 251;
+ --fastui-disabled: #e8ebef;
+ --fastui-disabled-rgb: 232, 235, 239;
+ --fastui-success: #16834a;
+ --fastui-success-rgb: 22, 131, 74;
+ --fastui-warning: #8c5b09;
+ --fastui-warning-rgb: 140, 91, 9;
+ --fastui-danger: #c93636;
+ --fastui-danger-rgb: 201, 54, 54;
+ --fastui-danger-fill: #c93636;
+ --fastui-danger-fill-rgb: 201, 54, 54;
+ --fastui-overlay: #151c2880;
+ --fastui-color-scheme: light;
+}
+[data-fastui-theme="dark"] {
+ --fastui-page: #111820;
+ --fastui-page-rgb: 17, 24, 32;
+ --fastui-workspace: #111820;
+ --fastui-workspace-rgb: 17, 24, 32;
+ --fastui-surface: #171e27;
+ --fastui-surface-rgb: 23, 30, 39;
+ --fastui-raised: #273342;
+ --fastui-raised-rgb: 39, 51, 66;
+ --fastui-text: #f4f7fa;
+ --fastui-text-rgb: 244, 247, 250;
+ --fastui-secondary: #c5cfdb;
+ --fastui-secondary-rgb: 197, 207, 219;
+ --fastui-muted: #9aa8b9;
+ --fastui-muted-rgb: 154, 168, 185;
+ --fastui-border: #354252;
+ --fastui-border-rgb: 53, 66, 82;
+ --fastui-border-strong: #65758a;
+ --fastui-border-strong-rgb: 101, 117, 138;
+ --fastui-primary: #0e639c;
+ --fastui-primary-rgb: 14, 99, 156;
+ --fastui-primary-hover: #1177bb;
+ --fastui-primary-hover-rgb: 17, 119, 187;
+ --fastui-focus: #5ba3ea;
+ --fastui-focus-rgb: 91, 163, 234;
+ --fastui-selected: #094771;
+ --fastui-selected-rgb: 9, 71, 113;
+ --fastui-disabled: #273342;
+ --fastui-disabled-rgb: 39, 51, 66;
+ --fastui-success: #6acc96;
+ --fastui-success-rgb: 106, 204, 150;
+ --fastui-warning: #e6ba6a;
+ --fastui-warning-rgb: 230, 186, 106;
+ --fastui-danger: #ff848c;
+ --fastui-danger-rgb: 255, 132, 140;
+ --fastui-danger-fill: #b03030;
+ --fastui-danger-fill-rgb: 176, 48, 48;
+ --fastui-overlay: #00000099;
+ --fastui-color-scheme: dark;
+}
\ No newline at end of file
diff --git a/assets/gate.js b/assets/gate.js
index b2bcd0f..8489ce3 100644
--- a/assets/gate.js
+++ b/assets/gate.js
@@ -39,7 +39,7 @@
bounce();
return;
}
- fetch("/api/content/me", { credentials: "include" })
+ fetch(window.fastNewsApi("/api/content/me"), { credentials: "include" })
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then((payload) => {
if (!payload || !(payload.keyId || payload.userId)) throw new Error("no session");
diff --git a/assets/impression.js b/assets/impression.js
index d7db95b..3ae8c03 100644
--- a/assets/impression.js
+++ b/assets/impression.js
@@ -61,7 +61,7 @@
async function loadImpression() {
try {
- const response = await fetch("/api/content/impression", { credentials: "include" });
+ const response = await fetch(window.fastNewsApi("/api/content/impression"), { credentials: "include" });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "load failed");
const impression = payload.impression || {};
@@ -78,8 +78,8 @@
submit.disabled = true;
setStatus("正在保存…", "pending");
try {
- const profile = await fetch("/api/content/me", {credentials:"include"}).then(r => r.json());
- const response = await fetch("/api/content/impression", {
+ const profile = await fetch(window.fastNewsApi("/api/content/me"), {credentials:"include"}).then(r => r.json());
+ const response = await fetch(window.fastNewsApi("/api/content/impression"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json", ...(profile.csrf ? {"X-CSRF-Token":profile.csrf} : {}) },
diff --git a/assets/inbox.js b/assets/inbox.js
index a4e771e..688d32e 100644
--- a/assets/inbox.js
+++ b/assets/inbox.js
@@ -76,8 +76,8 @@
}
async function markRead(ids) {
try {
- const profile = await fetch("/api/content/me", {credentials:"include"}).then(r => r.json());
- const response = await fetch("/api/content/inbox", {
+ const profile = await fetch(window.fastNewsApi("/api/content/me"), {credentials:"include"}).then(r => r.json());
+ const response = await fetch(window.fastNewsApi("/api/content/inbox"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json", ...(profile.csrf ? {"X-CSRF-Token":profile.csrf} : {}) },
diff --git a/assets/report.css b/assets/report.css
new file mode 100644
index 0000000..e349a6a
--- /dev/null
+++ b/assets/report.css
@@ -0,0 +1,43 @@
+/* Publications own their paper palette. No application theme, storage or OS
+ preference can change the document body, either on screen or on paper. */
+html.fastnews-report {
+ color-scheme: light;
+ --app-bg: #ffffff;
+ --muted-bg: #eef0f3;
+ --surface: #ffffff;
+ --surface-muted: #f7f8fa;
+ --soft: #f7f8fa;
+ --border: #dfe3e8;
+ --input-border: #687384;
+ --primary: #155fb8;
+ --primary-deep: #104a91;
+ --primary-soft: #eef0f3;
+ --accent: #8c5b09;
+ --accent-soft: #fff8e6;
+ --accent-border: #dfe3e8;
+ --accent-text: #8c5b09;
+ --text: #151c28;
+ --muted: #4b5565;
+ --subtle: #687384;
+}
+.fastnews-report body {
+ background: #ffffff;
+ color: #151c28;
+ font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
+}
+.fastnews-report :is(.surface, .paper, .article, .sidebar, .topbar) {
+ background: #ffffff;
+ border-color: #dfe3e8;
+ box-shadow: none;
+}
+.fastnews-report :is(.paper-summary, .article-intro, .article-summary) { color: #151c28; }
+.fastnews-report :is(.paper-title a, .article-title a) { color: #155fb8; }
+.fastnews-report .toc-list a:hover { background: #eef0f3; }
+.fastnews-report :is(.brand-mark, .header-icon) { border-color: #dfe3e8; }
+.fastnews-report .badge { background: #fff8e6; border-color: #dfe3e8; color: #8c5b09; }
+@media print {
+ html.fastnews-report { color-scheme: light; }
+ .fastnews-report body { background: #ffffff; color: #151c28; }
+ .fastnews-report :is(.sidebar, .topbar, .mobile-toc, .paper-actions, .fastread-open) { display: none; }
+ .fastnews-report :is(.paper-summary, .article-intro, .article-summary) { color: #151c28; }
+}
diff --git a/assets/theme.js b/assets/theme.js
new file mode 100644
index 0000000..1be2f5f
--- /dev/null
+++ b/assets/theme.js
@@ -0,0 +1,14 @@
+// Startup has already resolved the first paint. No second media/storage owner.
+const colorMode = window.fastNewsColorMode;
+const button = document.getElementById('themeToggle');
+function applyTheme() {
+ const dark = colorMode.resolved === 'dark';
+ if (!button) return;
+ button.querySelector('.theme-sun').hidden = dark;
+ button.querySelector('.theme-moon').hidden = !dark;
+ button.title = dark ? '切换浅色模式' : '切换深色模式';
+ button.setAttribute('aria-label', button.title);
+}
+applyTheme();
+colorMode.subscribe(applyTheme);
+button?.addEventListener('click', () => colorMode.setMode(colorMode.resolved === 'dark' ? 'light' : 'dark'));
diff --git a/assets/workbench.css b/assets/workbench.css
new file mode 100644
index 0000000..d1440ac
--- /dev/null
+++ b/assets/workbench.css
@@ -0,0 +1,22 @@
+/* Host semantic slots; shared package is the only editable palette. */
+html[data-fastui-theme] {
+ --app-bg: var(--fastui-page); --muted-bg: var(--fastui-workspace);
+ --surface: var(--fastui-surface); --soft: var(--fastui-workspace);
+ --surface-muted: var(--fastui-workspace);
+ --border: var(--fastui-border); --input-border: var(--fastui-border-strong);
+ --primary: var(--fastui-primary); --primary-deep: var(--fastui-primary-hover);
+ --primary-soft: var(--fastui-selected); --primary-border: var(--fastui-border-strong);
+ --text: var(--fastui-text); --muted: var(--fastui-secondary); --subtle: var(--fastui-muted);
+ --danger: var(--fastui-danger); --danger-soft: var(--fastui-danger-fill); --focus: var(--fastui-focus);
+ --author-panel: var(--fastui-surface); --author-muted: var(--fastui-secondary);
+ --author-line: var(--fastui-border); --author-chip: var(--fastui-selected); --author-error: var(--fastui-danger);
+}
+html[data-fastui-theme] .button-primary {
+ background: var(--fastui-primary); color: var(--fastui-on-primary); border-color: var(--fastui-primary); box-shadow: none;
+}
+html[data-fastui-theme] .button-primary:hover { background: var(--fastui-primary-hover); }
+html[data-fastui-theme] .surface { border-radius: 8px; box-shadow: none; }
+html[data-fastui-theme] body { background: var(--fastui-page); color: var(--fastui-text); }
+html[data-fastui-theme] :is(.brand-mark, .sidebar-logo) { background: var(--fastui-primary); color: var(--fastui-on-primary); }
+html[data-fastui-theme] :is(button, a, input, textarea, select):focus-visible { outline: 2px solid var(--fastui-focus); outline-offset: 2px; }
+html[data-fastui-theme] #fastnews-account-link { background: var(--fastui-surface) !important; color: var(--fastui-focus) !important; border-color: var(--fastui-border) !important; }
diff --git a/docs/FASTUI.md b/docs/FASTUI.md
new file mode 100644
index 0000000..136fdde
--- /dev/null
+++ b/docs/FASTUI.md
@@ -0,0 +1,22 @@
+# FastUI 0.1.2
+
+构建时执行,生成资产与页面一同部署:
+
+```sh
+python scripts/supply_fastui.py
+python generate_homepage.py
+```
+
+离线构建可将第一条改为 `python scripts/supply_fastui.py --artifact /path/to/ui-foundation-0.1.2.tgz`。两种入口校验同一个固定 SHA-256;`assets/fastui/provenance.json` 记录版本、官方 npm tarball URL、包摘要和逐文件摘要。该目录是生成物,升级时从公开 registry metadata 确认实际 tarball URL 并下载核准版本和摘要,再改供应脚本并重新构建;不编辑供应的 CSS/JS。运行时只请求本站资产,无 npm/CDN。
+
+主页模板生成根页面和 top-conf、secnews、field-briefing、summary-brief、inbox、impression 七个入口。head 从 `asset_prefix` 同步加载经典脚本 `fastui/theme-init.js`,先迁移旧 key 再由唯一 foundation controller 初始化首屏;`gate.js` 放行内容前颜色已解析。`assets/theme.js` 为 defer 脚本,只连接同一 controller 的按钮/图标,不再初始化或监听系统/storage。
+
+沿用 `fastresearch-theme`,缺失时一次性读取旧 `fastnews.theme`;保留 `light/dark/system`,不双写,不由 pageshow 维护第二套主题。供应器将已校验的 foundation `index.js` 去掉固定 ESM export list,再与 `scripts/theme-init.js` 适配代码封装成经典入口;源文件与生成文件摘要都进入 provenance。不会手工 vendor 或修改 foundation 实现。
+
+会议/资讯报告是固定浅色出版文档:`assets/report.css` 单独拥有纸张、正文和打印颜色;screen chrome 使用同一蓝灰出版配色,报告不加载站点 workbench CSS、foundation 颜色 bootstrap,也不读取读者的 storage/OS 主题。模板已删除旧的深色 media/绿色 console 色板,输出内嵌出版 CSS;主页生成器为既有19份归档移除上一轮主题注入并内嵌相同出版边界,保留正文与布局。打印为白底深字,禁用 JS 仍成立。
+
+本变更没有新增归档对外部 UI 包的运行依赖;历史 FastRead CSS/JS 链接仍存在,因此不将整个下载 HTML 宣称为完全自包含。正文和报告数据不会进入 FastUI 包。
+
+账号页源模板为 `prompt/account.html`,供应脚本生成 `assets/account.html` 并内嵌颜色资产;private 模式认证前无需请求被登录保护的 `/assets` 文件。修改账号界面应改源模板后重新执行供应脚本。
+
+主题/打印回归:设置 `PYTHONPATH=.` 后执行 `python tests/theme_print_browser.py /path/to/chromium /path/to/evidence`(需要 Playwright、WeasyPrint、PyMuPDF 和平台 Pango)。覆盖延迟应用脚本首屏、同一 controller 的按钮、OS/保存偏好相反组合、模板/真实归档 screen/print、Git HEAD 对照、全部归档打印及真正 WeasyPrint PDF/像素/文本。Windows 可通过 `WEASYPRINT_DLL_DIRECTORIES` 指向已有 Pango/GTK DLL。
diff --git a/docs/LOCAL-ACCOUNTS.md b/docs/LOCAL-ACCOUNTS.md
index 7a94782..2422fdd 100644
--- a/docs/LOCAL-ACCOUNTS.md
+++ b/docs/LOCAL-ACCOUNTS.md
@@ -4,7 +4,9 @@ FastNews 的本地登录、邀请注册和恢复独立于 FastCAS。FastCAS 只
## 本地运行
-使用 `uv sync` 安装本地账号依赖,启用 FastCAS 时使用 `uv sync --extra fastcas` 安装锁定的 PyPI FastCAS SDK 0.1.2。服务器数据目录由 `FASTNEWS_DATA_DIR` 指定,默认 `.fastnews-data`;SQLite 保存稳定用户 UUID、密码哈希、摘要会话、一次性凭证和个人内容。新目录权限 0700,数据库 0600。
+使用 `uv sync --locked` 安装本地账号及默认 dev 依赖;运行部署可用 `uv sync --locked --no-dev`。启用 FastCAS 时使用 `uv sync --locked --extra fastcas`,从 PyPI 安装固定发行版 `fastcas-sdk==0.1.2`(导入名 `fastcas`),完整 uv.lock 记录制品摘要,不需要相邻 SDK checkout。关闭 CAS 的部署不需要此 extra。服务器数据目录由 `FASTNEWS_DATA_DIR` 指定,默认 `.fastnews-data`;SQLite 保存稳定用户 UUID、密码哈希、摘要会话、一次性凭证和个人内容。新目录权限 0700,数据库 0600。
+
+浏览器/打印回归环境用 `uv sync --locked --extra fastcas --group browser`,额外安装 Playwright 与 PyMuPDF;它们仅在 browser 依赖组,不属于运行时 dependencies。测试使用已有 Chromium 时直接传其路径;需要 Playwright 管理的浏览器时另执行 `uv run --locked --group browser python -m playwright install chromium`。WeasyPrint 的平台 Pango/GTK 运行库仍需由宿主提供;安装 Python 依赖不等于这些系统库已安装。主题/打印场景入口为 `PYTHONPATH=. uv run --locked --group browser python tests/theme_print_browser.py <证据目录>`;该场景详见 `FASTUI.md`,本次依赖迁移不修改测试脚本。
管理员命令:
diff --git a/fastui_report.py b/fastui_report.py
new file mode 100644
index 0000000..9ed1a24
--- /dev/null
+++ b/fastui_report.py
@@ -0,0 +1,37 @@
+"""Publication styling is independent from application controls and color mode."""
+from pathlib import Path
+import re
+
+ROOT = Path(__file__).resolve().parent
+START = ''
+END = ''
+
+
+def supply_archived_reports():
+ """Keep archived body/layout, replace the former site-theme injection.
+
+ The publication CSS is embedded so this change adds no external runtime
+ dependency to downloaded archives. Existing FastRead links are preserved.
+ """
+ style = (ROOT / 'assets/report.css').read_text(encoding='utf-8')
+ for directory in (Path('top-conf/data/report'), Path('secnews/data/report')):
+ for path in sorted(directory.glob('*.html')):
+ content = path.read_text(encoding='utf-8')
+ if '' not in content:
+ continue
+ content = re.sub(re.escape(START) + r'.*?' + re.escape(END) + r'\s*', '', content, flags=re.S)
+ content = re.sub(r']*href="[^"]*(?:fastui/(?:tokens|controls)\.css|workbench\.css)"[^>]*>\s*', '', content)
+ content = re.sub(r'\s*', '', content)
+ # These inventoried archives have a plain root.
+ if not re.search(r']*class="[^"]*\bfastnews-report\b', content):
+ content = re.sub(r'(]*)(>)', r'\1 class="fastnews-report"\2', content, count=1)
+ block = START + '\n\n' + END + '\n'
+ updated = content.replace('', block + '', 1)
+ if updated != path.read_text(encoding='utf-8'):
+ path.write_text(updated, encoding='utf-8')
+
+
+def report_assets():
+ # Report templates intentionally receive no foundation tokens or bootstrap:
+ # print/PDF must not depend on JS or the reader's application preference.
+ return {'report_css': (ROOT / 'assets/report.css').read_text(encoding='utf-8')}
diff --git a/field-briefing/index.html b/field-briefing/index.html
index 8747c2b..592d62c 100644
--- a/field-briefing/index.html
+++ b/field-briefing/index.html
@@ -10,37 +10,37 @@
+
+
+
+
@@ -311,6 +522,7 @@
+