From 7e531da8a3efc0344554de53fad964cebcef81dd Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Tue, 10 Nov 2026 19:09:05 +0800 Subject: [PATCH 1/4] fix: preserve deployment prefix in personal content APIs Route personal API requests through fastNewsApi and cover root and nested mounts with script and browser contracts. Refs #4. --- assets/authors-nav.js | 4 +- assets/authors.js | 6 +- assets/gate.js | 2 +- assets/impression.js | 6 +- assets/inbox.js | 4 +- tests/personal-api-paths.cjs | 69 ++++++++++++ tests/personal_api_browser_contract.py | 139 +++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 tests/personal-api-paths.cjs create mode 100644 tests/personal_api_browser_contract.py diff --git a/assets/authors-nav.js b/assets/authors-nav.js index 2a40789..b973373 100644 --- a/assets/authors-nav.js +++ b/assets/authors-nav.js @@ -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/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/tests/personal-api-paths.cjs b/tests/personal-api-paths.cjs new file mode 100644 index 0000000..895a7d9 --- /dev/null +++ b/tests/personal-api-paths.cjs @@ -0,0 +1,69 @@ +// Run complete production scripts in V8; DOM/fetch are host doubles, not browser E2E. +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const tick = () => new Promise(resolve => setImmediate(resolve)); +class Element { + constructor() { this.listeners = {}; this.value = ''; this.dataset = {}; this.classList = {add(){}, remove(){}, toggle(){}, contains(){return false}}; } + addEventListener(k, f) { (this.listeners[k] ||= []).push(f); } + emit(k, event = {}) { return Promise.all((this.listeners[k] || []).map(f => f({preventDefault(){}, ...event}))); } + setAttribute(){} removeAttribute(){} append(){} focus(){} querySelector(){return null} +} +async function run(prefix, configured, publicReports) { + const calls = [], nodes = new Map(), document = new Element(); + document.getElementById = id => { if (!nodes.has(id)) nodes.set(id, new Element()); return nodes.get(id); }; + document.querySelectorAll = () => []; + document.querySelector = () => null; + document.currentScript = {src: 'https://fixture.test' + prefix + '/assets/gate.js'}; + document.documentElement = new Element(); document.body = new Element(); document.readyState = 'loading'; + const window = new Element(); + Object.assign(window, { + location: new URL('https://fixture.test' + prefix + '/inbox/index.html'), + FASTNEWS_PUBLIC_REPORTS: publicReports, + localStorage: {getItem(){return null}, setItem(){}}, history: {replaceState(){}}, + setTimeout(){return 1}, clearTimeout(){}, + }); + if (configured) window.FASTNEWS_PUBLIC_PATH = prefix + '/'; + const now = new Date(); + const shanghai = new Date(now.getTime() + now.getTimezoneOffset() * 60000 + 8 * 3600000); + const date = `${shanghai.getFullYear()}-${String(shanghai.getMonth()+1).padStart(2,'0')}-${String(shanghai.getDate()).padStart(2,'0')}`; + const inbox = {items: [{id:'today', date, kind:'daily-paper', title:'Fixture', read:false}], unread:1}; + window.FASTNEWS_INBOX = inbox; + document.dispatchEvent = event => document.emit(event.type, event); + let active = ''; + const fetch = async (url, options = {}) => { + calls.push({script: active, url, method: options.method || 'GET', options}); + const payload = url.endsWith('/me') ? {userId:'u', csrf:'c', authors:[], impression:{text:'saved'}, inboxUnread:1} + : url.endsWith('/authors') ? {authors:[{id:'a', name:'Fixture', homepage:'https://example.test', tags:[]}], customTags:[]} + : url.endsWith('/inbox') ? inbox : {impression:{text:'saved'}}; + return {ok:true, json:async () => payload}; + }; + const context = vm.createContext({window, document, fetch, URL, URLSearchParams, console, + CSS:{escape:x=>x}, HTMLElement:Element, CustomEvent:class {constructor(type, init){this.type=type; Object.assign(this,init)}}}); + for (const file of ['gate.js', 'authors-nav.js', 'impression.js', 'inbox.js', 'authors.js']) { + active = file; + vm.runInContext(fs.readFileSync(path.join(__dirname, '../assets', file), 'utf8'), context, {filename:file}); + await tick(); await tick(); + if (file === 'impression.js') await nodes.get('impressionForm').emit('submit'); + if (file === 'authors.js') { + await nodes.get('authorList').emit('click', {target:{closest(selector){return selector === '[data-delete-id]' ? {dataset:{deleteId:'a'}} : null}}}); + document.visibilityState = 'hidden'; await document.emit('visibilitychange'); await tick(); + } + } + assert.equal(calls.length, publicReports ? 10 : 11); + for (const call of calls) { + assert(call.url.startsWith(prefix + '/api/content/'), `${call.script}: ${call.url} escaped ${prefix || '/'}`); + assert.equal(call.options.credentials, 'include'); + if (call.method === 'PUT') assert.equal(call.options.headers['X-CSRF-Token'], 'c'); + } + assert.deepEqual(calls.filter(c => c.method === 'PUT').map(c => c.url), + ['/impression', '/inbox', '/authors'].map(p => prefix + '/api/content' + p)); + return {prefix: prefix || '/', configured, publicReports, calls: calls.map(({script,url,method}) => ({script,url,method}))}; +} +(async () => { + for (const prefix of ['', '/news', '/lab/news']) + for (const configured of [false, true]) + for (const publicReports of [false, true]) + console.log(JSON.stringify(await run(prefix, configured, publicReports))); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/personal_api_browser_contract.py b/tests/personal_api_browser_contract.py new file mode 100644 index 0000000..5870969 --- /dev/null +++ b/tests/personal_api_browser_contract.py @@ -0,0 +1,139 @@ +"""Real browser -> prefix-stripping proxy -> FastNewsHandler -> SQLite. + +Run with PYTHONPATH=. and an installed Playwright; pass Chromium and output JSON paths. +No DOM, fetch, account, or content handler doubles are used in this scenario. +""" +import http.client +import json +import os +import re +import sys +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from playwright.sync_api import expect, sync_playwright + +from fastnews_accounts import Accounts +from fastnews_cas import CASService +from inbox_push import shanghai_today +from serve import FastNewsHandler + + +def run(browser, prefix, private): + with tempfile.TemporaryDirectory() as temp: + os.environ.update(FASTNEWS_ROOT=str(Path(__file__).resolve().parents[1]), + FASTNEWS_COOKIE_SECURE='false', FASTNEWS_PUBLIC_PATH=prefix, + FASTNEWS_REQUIRE_LOGIN=str(private).lower()) + backend = ThreadingHTTPServer(('127.0.0.1', 0), FastNewsHandler) + accounts = backend.accounts = Accounts(temp) + backend.cas = CASService(accounts, env={}) + user = accounts.create('reader@example.test', 'browser-password') + accounts.personal(user, 'authors', {'authors': [{'id': 'a', 'name': 'Fixture Author', + 'homepage': 'https://example.test', 'tags': ['security']}], 'customTags': ['security']}) + accounts.personal(user, 'impression', {'text': 'Initial interests'}) + accounts.personal(user, 'inbox', [{'id': 'today', 'date': shanghai_today(), + 'kind': 'daily-paper', 'title': 'Fixture paper', 'read': False}]) + requests = [] + + class Proxy(BaseHTTPRequestHandler): + def forward(self): + requests.append((self.command, self.path)) + if not self.path.startswith(prefix + '/'): + self.send_error(404) + return + connection = http.client.HTTPConnection('127.0.0.1', backend.server_port, timeout=10) + try: + body = self.rfile.read(int(self.headers.get('Content-Length', 0))) + headers = {key: value for key, value in self.headers.items() + if key.lower() not in {'connection', 'accept-encoding'}} + connection.request(self.command, self.path[len(prefix):], body, headers) + response = connection.getresponse() + data = response.read() + self.send_response(response.status) + for key, value in response.getheaders(): + if key.lower() not in {'connection', 'transfer-encoding'}: + self.send_header(key, value) + self.end_headers() + self.wfile.write(data) + finally: + connection.close() + do_GET = do_POST = do_PUT = forward + + proxy = ThreadingHTTPServer(('127.0.0.1', 0), Proxy) + origin = f'http://127.0.0.1:{proxy.server_port}' + os.environ['FASTNEWS_PUBLIC_ORIGIN'] = origin + threads = [threading.Thread(target=server.serve_forever, daemon=True) for server in [backend, proxy]] + for thread in threads: + thread.start() + context = browser.new_context() + page = context.new_page() + errors = [] + page.on('pageerror', lambda error: errors.append(str(error))) + try: + # Exercise the public/private document policy before real local login. + page.goto(origin + prefix + '/') + if private: + page.wait_for_url(origin + prefix + '/login') + else: + expect(page.locator('html')).to_have_class(re.compile('fastnews-ready')) + init = context.request.get(origin + prefix + '/api/auth/session-init').json() + response = context.request.post(origin + prefix + '/api/auth/login', + data={'email': 'reader@example.test', 'password': 'browser-password'}, + headers={'X-CSRF-Token': init['csrf'], 'Origin': origin}) + assert response.status == 200 + page.goto(origin + prefix + '/index.html#authors') + expect(page.locator('[data-delete-id="a"]')).to_be_visible() + page.locator('#openImpression').click() + expect(page.locator('#impressionText')).to_have_value('Initial interests') + page.locator('#impressionText').fill('Browser saved interests') + with page.expect_response(lambda r: r.request.method == 'PUT' and r.url.endswith('/api/content/impression')) as saved: + page.locator('#impressionSubmit').click() + assert saved.value.status == 200 + page.reload() + expect(page.locator('#impressionText')).to_have_value('Browser saved interests') + with page.expect_response(lambda r: r.request.method == 'PUT' and r.url.endswith('/api/content/authors')) as saved: + page.locator('[data-delete-id="a"]').click() + assert saved.value.status == 200 + page.reload() + expect(page.locator('#authorList')).to_contain_text('还没有添加关注作者') + page.goto(origin + prefix + '/inbox/index.html') + expect(page.locator('#inboxStatUnread')).to_have_text('0') + page.wait_for_load_state('networkidle') + page.reload() + expect(page.locator('#inboxStatUnread')).to_have_text('0') + page.wait_for_load_state('networkidle') + assert accounts.personal(user, 'impression')['text'] == 'Browser saved interests' + assert accounts.personal(user, 'authors') == {'authors': [], 'customTags': ['security']} + assert accounts.personal(user, 'inbox')[0]['read'] is True + assert context.request.put(origin + prefix + '/api/content/impression', data={'text': 'forged'}).status == 403 + context.clear_cookies() + context.add_cookies([{'name': 'fastnews_session', 'value': 'invalid', 'url': origin}]) + assert context.request.get(origin + prefix + '/api/content/me').status == 401 + api_requests = [(method, path) for method, path in requests if '/api/content/' in path] + assert api_requests and all(path.startswith(prefix + '/api/content/') for _, path in api_requests) + assert not errors, errors + return {'prefix': prefix or '/', 'private': private, 'impression': 'persisted', + 'authors': 'deleted and persisted', 'inboxUnread': 0, 'pageErrors': errors, + 'requests': sorted(set(api_requests))} + finally: + context.close() + for server, thread in zip([backend, proxy], threads): + server.shutdown(); server.server_close(); thread.join(timeout=5) + + +if __name__ == '__main__': + with sync_playwright() as playwright: + browser = playwright.chromium.launch(executable_path=sys.argv[1], headless=True) + try: + results = [] + for prefix in ['', '/news', '/lab/news']: + for private in [False, True]: + result = run(browser, prefix, private) + results.append(result) + print(json.dumps(result, ensure_ascii=False)) + Path(sys.argv[2]).write_text(json.dumps({'browser': browser.version, 'scenarios': results}, + ensure_ascii=False, sort_keys=True, indent=2), encoding='utf-8') + finally: + browser.close() From ddb6fe475fa70c53cb6b978bd68972166e6ad459 Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Wed, 11 Nov 2026 06:05:53 +0800 Subject: [PATCH 2/4] refactor: share site UI assets and isolate report publication styles Use immutable public release artifacts and retain product-specific behavior. Independent review and real release installation recorded in the sharing handoff. --- .gitattributes | 6 + assets/account.html | 309 +++++++++++++-- assets/authors-nav.js | 2 +- assets/fastui/LICENSE | 21 + assets/fastui/NOTICE.md | 23 ++ assets/fastui/color-mode.js | 75 ++++ assets/fastui/controls.css | 61 +++ assets/fastui/index.js | 79 ++++ assets/fastui/provenance.json | 21 + assets/fastui/theme-init.js | 93 +++++ assets/fastui/tokens.css | 98 +++++ assets/report.css | 43 +++ assets/theme.js | 14 + assets/workbench.css | 22 ++ docs/FASTUI.md | 22 ++ docs/LOCAL-ACCOUNTS.md | 4 +- fastui_report.py | 37 ++ field-briefing/index.html | 281 +++++++++++--- generate_homepage.py | 2 + impression/index.html | 292 +++++++++++--- inbox/index.html | 279 ++++++++++++-- index.html | 363 +++++++++++++++--- prompt/account.html | 30 ++ prompt/homepage.html.j2 | 52 +-- pyproject.toml | 9 +- scripts/supply_fastui.py | 70 ++++ scripts/theme-init.js | 13 + secnews/data/report/2026.3.1-report.html | 50 ++- secnews/data/report/2026.3.2-report.html | 50 ++- secnews/data/report/2026.9.1-report.html | 50 ++- secnews/generate_pdf.py | 5 + secnews/index.html | 279 ++++++++++++-- secnews/prompt/newspaper.html.j2 | 68 +--- secnews/render_daily_report.py | 7 + summary-brief/index.html | 281 +++++++++++--- tests/personal_api_browser_contract.py | 4 +- tests/theme_print_browser.py | 184 +++++++++ top-conf/data/report/CCS_2023_Report.html | 50 ++- top-conf/data/report/CCS_2024_Report.html | 50 ++- top-conf/data/report/CCS_2025_Report.html | 50 ++- top-conf/data/report/CCS_2026_Report.html | 50 ++- top-conf/data/report/IEEE-SP_2023_Report.html | 50 ++- top-conf/data/report/IEEE-SP_2024_Report.html | 50 ++- top-conf/data/report/IEEE-SP_2025_Report.html | 50 ++- top-conf/data/report/IEEE-SP_2026_Report.html | 50 ++- top-conf/data/report/NDSS_2023_Report.html | 50 ++- top-conf/data/report/NDSS_2024_Report.html | 50 ++- top-conf/data/report/NDSS_2025_Report.html | 50 ++- top-conf/data/report/NDSS_2026_Report.html | 50 ++- top-conf/data/report/USENIX_2023_Report.html | 50 ++- top-conf/data/report/USENIX_2024_Report.html | 50 ++- top-conf/data/report/USENIX_2025_Report.html | 50 ++- top-conf/data/report/USENIX_2026_Report.html | 50 ++- top-conf/generate_conf_report.py | 6 + top-conf/index.html | 345 +++++++++++++---- top-conf/prompt/conf_report.html.j2 | 90 +---- uv.lock | 119 +++++- 57 files changed, 4046 insertions(+), 613 deletions(-) create mode 100644 .gitattributes create mode 100644 assets/fastui/LICENSE create mode 100644 assets/fastui/NOTICE.md create mode 100644 assets/fastui/color-mode.js create mode 100644 assets/fastui/controls.css create mode 100644 assets/fastui/index.js create mode 100644 assets/fastui/provenance.json create mode 100644 assets/fastui/theme-init.js create mode 100644 assets/fastui/tokens.css create mode 100644 assets/report.css create mode 100644 assets/theme.js create mode 100644 assets/workbench.css create mode 100644 docs/FASTUI.md create mode 100644 fastui_report.py create mode 100644 prompt/account.html create mode 100644 scripts/supply_fastui.py create mode 100644 scripts/theme-init.js create mode 100644 tests/theme_print_browser.py 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/assets/account.html b/assets/account.html index f4b5319..cb7a0da 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 b973373..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); 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..0bb35d9 --- /dev/null +++ b/assets/fastui/provenance.json @@ -0,0 +1,21 @@ +{ + "package": "@fastrd/ui-foundation", + "version": "0.1.0", + "url": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "sha256": "5c87e8f60c2c349c98b17349396a4f6ac335de30f92d0d0b8ec2171b951ebd9e", + "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": "d9e54224806ad3e339ebf5e06525e3c027e5394f6cfb1ae0a7f2e9e397659e38", + "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..3c1dd57 --- /dev/null +++ b/assets/fastui/theme-init.js @@ -0,0 +1,93 @@ +// Generated from @fastrd/ui-foundation 0.1.0; 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/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..a37c62f --- /dev/null +++ b/docs/FASTUI.md @@ -0,0 +1,22 @@ +# FastUI 0.1.0 + +构建时执行,生成资产与页面一同部署: + +```sh +python scripts/supply_fastui.py +python generate_homepage.py +``` + +离线构建可将第一条改为 `python scripts/supply_fastui.py --artifact /path/to/fastrd-ui-foundation-0.1.0.tgz`。两种入口校验同一个固定 SHA-256;`assets/fastui/provenance.json` 记录版本、release 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 51892b4..0011fea 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` 安装同级 FastCAS Python SDK。服务器数据目录由 `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.1`(导入名 `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']*src="[^"]*fastui/color-mode\.js"[^>]*>\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 @@ +
  • 安全资讯3
  • 关注作者0
  • - +
    @@ -365,7 +577,7 @@ -
    更新于 2026-09-25 12:03 UTC
    +
    更新于 2026-11-10 17:38 UTC
    - +
    @@ -365,7 +577,7 @@ -
    更新于 2026-09-25 12:03 UTC
    +
    更新于 2026-11-10 17:38 UTC
    - +
    @@ -365,7 +577,7 @@ -
    更新于 2026-09-25 12:03 UTC
    +
    更新于 2026-11-10 17:38 UTC
    - +
    @@ -365,7 +577,7 @@ -
    更新于 2026-09-25 12:03 UTC
    +
    更新于 2026-11-10 17:38 UTC
    - +
    @@ -365,7 +577,7 @@ -
    更新于 2026-09-25 12:03 UTC
    +
    更新于 2026-11-10 17:38 UTC
    - +
    @@ -365,7 +577,7 @@ -
    更新于 2026-09-25 12:03 UTC
    +
    更新于 2026-11-10 17:38 UTC
    - +
    @@ -365,7 +577,7 @@ -
    更新于 2026-09-25 12:03 UTC
    +
    更新于 2026-11-10 17:38 UTC