diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml index 4e54fb0..3bc0cc9 100644 --- a/.github/workflows/tag-ci.yml +++ b/.github/workflows/tag-ci.yml @@ -45,7 +45,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: fastwrite diff --git a/apps/web/package.json b/apps/web/package.json index c1db53c..6003f66 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": "0.1.2", + "@fastrd/ui-react": "0.1.2", "@fastwrite/shared": "workspace:*", "@fontsource-variable/geist": "5.3.0", "@fontsource-variable/ibm-plex-sans": "5.3.0", diff --git a/apps/web/scripts/fastui-browser.mjs b/apps/web/scripts/fastui-browser.mjs new file mode 100644 index 0000000..ee7e8de --- /dev/null +++ b/apps/web/scripts/fastui-browser.mjs @@ -0,0 +1,145 @@ +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 { + for (const [mode, system] of [['dark', 'light'], ['light', 'dark']]) { + const cold = await context.newPage(); + await cold.emulateMedia({ colorScheme: system }); + await cold.addInitScript(mode => { + localStorage.setItem('fastwrite.theme', mode); + const observer = new MutationObserver(() => { + if (!document.getElementById('root')?.childElementCount) return; + window.firstRenderedTheme = { host: document.documentElement.dataset.theme, foundation: document.documentElement.dataset.fastuiTheme }; + observer.disconnect(); + }); + observer.observe(document, { childList: true, subtree: true }); + }, mode); + await cold.goto(`${base}/projects/ui-project`); + await expect(cold.getByRole('button', { name: '工作区设置', exact: true })).toBeVisible(); + assert.deepEqual(await cold.evaluate(() => window.firstRenderedTheme), { host: mode, foundation: mode }, 'stored theme must apply before the first React render'); + assert.equal(await cold.evaluate(() => localStorage.getItem('fastwrite.theme')), mode); + await cold.evaluate(() => localStorage.removeItem('fastwrite.theme')); + await cold.close(); + } + scenarios.push('saved light/dark pin is applied before the first React render, independently of the OS'); + 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 expect(name).toHaveCSS('font-family', /IBM Plex Sans Variable/); + await expect(name).toHaveCSS('height', '28px'); + await expect(name).toHaveCSS('border-radius', '4px'); + await expect(name).toHaveCSS('border-color', 'rgb(130, 144, 163)'); + 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'); + await peer.evaluate(() => localStorage.setItem('fastwrite.theme', 'system')); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark'); + await page.emulateMedia({ colorScheme: 'light' }); + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'light'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await expect(name).toBeFocused(); + await expect(name).toHaveValue('设置草稿保持'); + assert.equal(await original.evaluate(el => el.isConnected), true); + scenarios.push('system preference changes retain focused settings input and draft'); + const navigation = panel.getByRole('navigation', { name: '设置类别' }); + await navigation.getByRole('button', { name: '编译环境', exact: true }).click(); + await expect(panel.getByRole('combobox', { name: 'TeX 引擎' })).toBeVisible(); + await navigation.getByRole('button', { name: '外部 MCP', exact: true }).click(); + await expect(panel.getByRole('button', { name: '创建 30 天连接令牌' })).toBeVisible(); + scenarios.push('main typography and compact geometry apply to shared inputs; compilation and MCP settings stay reachable'); + 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); + const primary = panel.getByRole('button', { name: '保存更改', exact: true }); + await expect(primary).toHaveCSS('background-color', mode === 'dark' ? 'rgb(14, 99, 156)' : 'rgb(21, 95, 184)'); + await expect(primary).toHaveCSS('color', 'rgb(255, 255, 255)'); + await expect(name).toHaveCSS('font-family', /IBM Plex Sans Variable/); + await expect(name).toHaveCSS('background-color', mode === 'dark' ? 'rgb(23, 30, 39)' : 'rgb(255, 255, 255)'); + 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 22607b0..7c1839d 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"; @@ -32,7 +31,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..38ddc67 100644 --- a/apps/web/src/components/workspace/ProjectSettingsEditor.module.css +++ b/apps/web/src/components/workspace/ProjectSettingsEditor.module.css @@ -13,7 +13,8 @@ .title { display: flex; align-items: baseline; gap: 14px; min-width: 0; margin-bottom: 16px; } .title h1 { margin: 0; font-size: 22px; font-weight: 400; } .title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-size: 12px; } -.header > input { width: 100%; height: 30px; } +.editor :global(.fastui-input) { min-height: 0; height: var(--fw-control-height); padding: 4px var(--fw-control-padding-inline); font-size: var(--vscode-font-size); font-weight: var(--fw-font-weight-body); line-height: 18px; } +.header > :global(.fastui-input) { width: 100%; height: 30px; } .scope { display: flex; align-items: center; gap: 14px; margin-top: 12px; border-bottom: 1px solid var(--border); } .scope > span { padding: 9px 0; border-bottom: 2px solid var(--accent); font-size: 12px; } .scope small { color: var(--text-muted); font-size: 11px; } @@ -27,6 +28,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 bb441ab..8d8a131 100644 --- a/apps/web/src/components/workspace/ProjectSettingsEditor.tsx +++ b/apps/web/src/components/workspace/ProjectSettingsEditor.tsx @@ -6,7 +6,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, Section } from "../ui"; +import { Button, TextField } from "@fastrd/ui-react"; +import { Field, Icon, icons, Select, Section } from "../ui"; import { PublicationTargetFields } from "../ui/PublicationTargetFields"; import { harnessSettingsSaveDecision, type HarnessSettingsBaseline, type HarnessSettingsDraft } from "./harnessSettings"; import styles from "./ProjectSettingsEditor.module.css"; @@ -174,12 +175,12 @@ export function ProjectSettingsEditor({ project, tree, onSaved }: ProjectSetting
请更换搜索词或选择其他类别。
为工作区命名,并选择编译论文所用的源文件。