From 378f3e923b36448655d1f4f08d222ed6414dfd9c Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Wed, 11 Nov 2026 06:05:47 +0800 Subject: [PATCH 1/5] refactor: consume shared FastUI palette and settings controls Use immutable public release artifacts and retain product-specific behavior. Independent review and real release installation recorded in the sharing handoff. --- apps/web/package.json | 5 +- apps/web/scripts/fastui-browser.mjs | 101 +++++++++++ apps/web/src/App.tsx | 2 - .../ProjectSettingsEditor.module.css | 1 + .../workspace/ProjectSettingsEditor.tsx | 27 +-- apps/web/src/lib/theme.ts | 23 +-- apps/web/src/main.tsx | 5 + apps/web/src/styles.css | 91 +++------- apps/web/src/styles/tokens.css | 165 +++--------------- bun.lock | 6 + 10 files changed, 197 insertions(+), 229 deletions(-) create mode 100644 apps/web/scripts/fastui-browser.mjs diff --git a/apps/web/package.json b/apps/web/package.json index b9a94bf..63f3ab9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,9 +8,12 @@ "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", "e2e:visual": "node ../../scripts/e2e-visual-baseline.mjs", - "e2e:components": "node ../../scripts/e2e-component-gallery.mjs" + "e2e:components": "node ../../scripts/e2e-component-gallery.mjs", + "test:fastui:browser": "node scripts/fastui-browser.mjs" }, "dependencies": { + "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "@fastrd/ui-react": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz", "@fastwrite/shared": "workspace:*", "@siglum/engine": "^0.1.4", "@siglum/filesystem": "^0.2.1", diff --git a/apps/web/scripts/fastui-browser.mjs b/apps/web/scripts/fastui-browser.mjs new file mode 100644 index 0000000..48186e8 --- /dev/null +++ b/apps/web/scripts/fastui-browser.mjs @@ -0,0 +1,101 @@ +import { chromium, expect } from '@playwright/test'; +import { preview } from 'vite'; +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; + +// Production route and components; the service is substituted at its HTTP boundary. +const artifacts = 'test-results/fastui'; +await mkdir(artifacts, { recursive: true }); +const server = await preview({ configFile: false, preview: { host: '127.0.0.1', port: 0 } }); +const base = `http://127.0.0.1:${server.httpServer.address().port}`; +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block', colorScheme: 'light' }); +let project = { id: 'ui-project', name: 'FastUI project', mainDocument: 'main.tex', skill: { id: 'network-information-security', name: '网络与信息安全', venue: 'network-information-security', version: '2.0.0' }, source: { type: 'local', displayName: 'Workspace' }, version: 1, createdAt: '2026-11-10T00:00:00Z', updatedAt: '2026-11-10T00:00:00Z' }; +const file = { name: 'main.tex', path: 'main.tex', kind: 'text', size: 64, version: 1, updatedAt: project.updatedAt }; +const writes = [], errors = [], scenarios = []; +await context.addInitScript(() => { + localStorage.setItem('fastwrite.completion.enabled', 'false'); + localStorage.setItem('fastwrite.collaboration.enabled', 'false'); +}); +await context.route('**/api/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname; + let value = [], status = 200; + if (request.method() !== 'GET') writes.push({ path, method: request.method(), body: request.postDataJSON() }); + if (path === '/api/projects/ui-project') { + if (request.method() === 'PATCH') { project = { ...project, ...request.postDataJSON(), version: project.version + 1 }; } + value = project; + } else if (path === '/api/projects/ui-project/files') value = [{ ...file, type: 'file' }]; + else if (path === '/api/projects/ui-project/file') value = { file, content: '\\documentclass{article}\n\\begin{document}\nDraft\n\\end{document}\n' }; + else if (path === '/api/projects/ui-project/access') value = { role: 'owner', userId: 'writer', canWrite: true, canComment: true, canInvite: true, canManage: true, realtime: false, canCompile: true }; + else if (path === '/api/projects/ui-project/evidence-cockpit') value = { counts: { total: 0, supported: 0, unresolved: 0 }, claims: [] }; + else if (path.includes('/compile-results/')) value = null; + else if (path === '/api/harness-settings') { + assert.equal(request.method(), 'GET', 'saving general settings must not overwrite model credentials'); + value = { configured: false, source: 'none', wireAPI: 'chat' }; + } else if (path === '/api/auth/me') value = { id: 'writer', displayName: 'Writer', platformRole: 'user' }; + else if (path.endsWith('/history/working-status')) value = { staged: [], unstaged: [], untracked: [], branch: 'main' }; + else if (path.endsWith('/reference-selections')) value = { revision: 1, selections: [] }; + else if (path === '/api/harnesses') value = []; + await route.fulfill({ status, json: value }); +}); +try { + const page = await context.newPage(); + page.on('pageerror', error => { errors.push(error.message); console.error(error.stack); }); + await page.goto(`${base}/projects/ui-project`); + await page.getByRole('button', { name: '工作区设置', exact: true }).click(); + const panel = page.getByRole('tabpanel', { name: '设置', exact: true }); + const name = panel.getByLabel('项目名称', { exact: true }); + await expect(name).toHaveClass(/fastui-input/); + await name.fill(' '); + await expect(panel.getByRole('button', { name: '保存更改', exact: true })).toBeDisabled(); + await name.fill('设置草稿保持'); + await name.focus(); + const original = await name.elementHandle(); + const peer = await context.newPage(); + await peer.goto(base); + await peer.evaluate(() => localStorage.setItem('fastwrite.theme', 'dark')); + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(name).toBeFocused(); + await expect(name).toHaveValue('设置草稿保持'); + assert.equal(await original.evaluate(el => el.isConnected), true); + await expect(name).toHaveCSS('background-color', 'rgb(23, 30, 39)'); + scenarios.push('real settings tab consumes ui-react; cross-tab theme preserves input node, focus and draft'); + const navigation = panel.getByRole('navigation', { name: '设置类别' }); + await navigation.getByRole('button', { name: 'AI 与模型配置', exact: true }).click(); + const key = panel.getByLabel('API 密钥', { exact: true }); + await key.fill('model-key-draft'); + await key.press('Tab'); + await expect(panel.getByLabel('服务地址', { exact: true })).toBeFocused(); + await panel.getByRole('searchbox', { name: '搜索设置' }).fill('名称'); + await expect(name).toHaveValue('设置草稿保持'); + await navigation.getByRole('button', { name: '常规', exact: true }).click(); + // Leave the model draft empty to exercise the existing unchanged-model save decision. + await navigation.getByRole('button', { name: 'AI 与模型配置', exact: true }).click(); + await key.fill(''); + await navigation.getByRole('button', { name: '常规', exact: true }).click(); + await panel.getByRole('button', { name: '保存更改', exact: true }).click(); + await expect(panel.getByRole('status')).toHaveText('设置已保存'); + const saved = writes.find(write => write.path === '/api/projects/ui-project' && write.method === 'PATCH'); + assert.equal(saved.body.name, '设置草稿保持'); + assert.equal(writes.some(write => write.path === '/api/harness-settings'), false); + scenarios.push('label wiring, keyboard Tab, category/search retain drafts, controlled save retains model settings'); + // Use the existing workbench pane controls before checking the compact settings surface. + await page.getByRole('button', { name: '隐藏面板', exact: true }).click(); + await page.getByRole('button', { name: '收起 PDF 预览', exact: true }).click(); + await page.getByRole('button', { name: '收起文件面板', exact: true }).click(); + for (const width of [1440, 1024, 390]) for (const mode of ['light', 'dark']) { + await page.setViewportSize({ width, height: 1000 }); + await peer.evaluate(mode => localStorage.setItem('fastwrite.theme', mode), mode); + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', mode); + await expect(name).toHaveValue('设置草稿保持'); + assert.ok(await panel.evaluate(el => [...el.querySelectorAll('input')].filter(input => input.getClientRects().length).every(input => input.getBoundingClientRect().right <= el.getBoundingClientRect().right + 1))); + await page.screenshot({ path: `${artifacts}/${mode}-${width}.png` }); + } + assert.deepEqual(errors, []); + await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', scenarios, viewports: [1440, 1024, 390] }, null, 2)); + console.log('PASS FastWrite FastUI production settings browser scenarios'); +} finally { + await browser.close(); + await new Promise(resolve => server.httpServer.close(resolve)); +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3415811..8fcdfd1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,5 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { FASTWRITE_SAVE_EVENT, isSaveShortcut } from "./lib/keyboard"; -import { applyTheme, initialTheme } from "./lib/theme"; import { api } from "./api/client"; import { WorkbenchNotifications } from "./components/workbench/WorkbenchNotifications"; @@ -29,7 +28,6 @@ export function App() { const parameters = new URLSearchParams(window.location.search); return import.meta.env.VITE_FASTWRITE_DEVELOPMENT_ADMIN !== "true" && !["oidc", "cas", "fastcas"].some((provider) => parameters.get(provider) === "complete"); }); - useEffect(() => { applyTheme(initialTheme()); }, []); useEffect(() => { const parameters = new URLSearchParams(window.location.search); if (import.meta.env.VITE_FASTWRITE_DEVELOPMENT_ADMIN === "true") { diff --git a/apps/web/src/components/workspace/ProjectSettingsEditor.module.css b/apps/web/src/components/workspace/ProjectSettingsEditor.module.css index a726934..9db2256 100644 --- a/apps/web/src/components/workspace/ProjectSettingsEditor.module.css +++ b/apps/web/src/components/workspace/ProjectSettingsEditor.module.css @@ -27,6 +27,7 @@ .section h2, .empty h2 { margin: 0; font-size: 18px; font-weight: 500; } .description { margin: -10px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.6; } .section > div { min-width: 0; } +.section :global(.fastui-input) { width: 100%; } .section :global(.settings-agent), .section:global(.settings-agent), .section :global(.settings-export) { padding: 0; border: 0; border-radius: 0; background: transparent; } .section :global(.settings-agent__actions) { flex-wrap: wrap; } .section :global(.settings-agent__actions) small { flex: 1 1 180px; } diff --git a/apps/web/src/components/workspace/ProjectSettingsEditor.tsx b/apps/web/src/components/workspace/ProjectSettingsEditor.tsx index e56097b..6ab3625 100644 --- a/apps/web/src/components/workspace/ProjectSettingsEditor.tsx +++ b/apps/web/src/components/workspace/ProjectSettingsEditor.tsx @@ -3,7 +3,8 @@ import { statusLabel } from "../../lib/labels"; import { useEffect, useMemo, useState } from "react"; import { isIgnoredWorkspacePath, WRITING_PROFILES, type AgentWireApi, type PaperProject, type ProjectAclAction, type PublicationTarget, type PublishedSkillDescriptor, type WritingProfile, type WorkspaceTreeNode } from "@fastwrite/shared"; import { api, downloadFile } from "../../api/client"; -import { Button, Field, Icon, icons, Select, TextField, Link } from "../ui"; +import { Button, TextField } from "@fastrd/ui-react"; +import { Field, Icon, icons, Select } from "../ui"; import { PublicationTargetFields } from "../ui/PublicationTargetFields"; import { harnessSettingsSaveDecision, type HarnessSettingsBaseline, type HarnessSettingsDraft } from "./harnessSettings"; import styles from "./ProjectSettingsEditor.module.css"; @@ -168,12 +169,12 @@ export function ProjectSettingsEditor({ project, tree, onSaved }: ProjectSetting

设置

{project.name}
- + setQuery(e.target.value)} autoFocus />
工作区此项目的设置
{terms.length && !matches.length ?

未找到设置项

请更换搜索词或选择其他类别。

: null} @@ -181,7 +182,7 @@ export function ProjectSettingsEditor({ project, tree, onSaved }: ProjectSetting

常规

为工作区命名,并选择编译论文所用的源文件。

- + {({ id, describedBy }) => setName(e.target.value)} />} setWireAPI(next as AgentWireApi)} options={[{ value: "chat", label: "Chat Completions 接口" }, { value: "responses", label: "Responses 接口" }]} /> -
密钥不会返回给客户端或写入项目文件,服务端重启后会清除。
+
密钥不会返回给客户端或写入项目文件,服务端重启后会清除。
{agentError ?
{agentError}
: null} {aclAvailable ?