diff --git a/apps/design-lab/README.md b/apps/design-lab/README.md new file mode 100644 index 0000000..06047c7 --- /dev/null +++ b/apps/design-lab/README.md @@ -0,0 +1,26 @@ +# design-lab · 设计系统可运行版 + +孟菲斯 × 拟物卡设计系统的 Vite MPA 实现。设计规格文档见 `design-refs/mockup/design-system/`(四册 PDF)。 + +## 结构 + +- 根目录每个 `*.html` 是一个入口,对应 `src/pages//main.tsx`(vite.config 自动扫描) +- `src/styles/tokens.css`:Tailwind v4 `@theme` 设计 token(撞色对 / 中性阶 / 章节色) +- `src/components/`:MemphisCard、RouteLine、Ticket、shapes、EntryGuide +- `src/lib/chapters.ts`:章节/关系/画像数据(改内容只动这里) + +## 命令 + +```bash +yarn dev # 本地开发 +yarn build # 构建 MPA +yarn test:e2e # E2E:结构契约断言 + 视觉基线 +yarn test:e2e:update # 有意的设计变更后,更新截图基线并提交 +``` + +## E2E 防画面偏差(两层) + +1. **结构契约**(`e2e/pages.spec.ts`):规格级断言——卡片 2px 描边/硬投影/纸叠三层、16:9 相框比例与内阴影、积木点实心数、站点 r6 半嵌(源骑黑影外缘/目标贴描边)、票券裁切式(overflow hidden + 头部无框 + 双打孔 + 撕线虚/实)。布局微调不误报,破坏规格必红。 +2. **视觉基线**(`e2e/visual.spec.ts` + `e2e/visual.spec.ts-snapshots/`):五页整页截图像素比对,容差 1%。**基线需随代码提交**;有意变更用 `yarn test:e2e:update` 生成新基线后一并提交。 + +测试在 `prefers-reduced-motion` 下运行(卡片的 8000ms 翻转与动效关闭,截图稳定)。 diff --git a/apps/design-lab/e2e/pages.spec.ts b/apps/design-lab/e2e/pages.spec.ts new file mode 100644 index 0000000..3b63eed --- /dev/null +++ b/apps/design-lab/e2e/pages.spec.ts @@ -0,0 +1,159 @@ +import { expect, test } from '@playwright/test' + +/** + * 设计系统结构契约断言——规格级防回归。 + * 布局微调不误报,但破坏设计规格(描边/投影/比例/半嵌/裁切)必红。 + */ + +const PAGES = ['index', 'colors', 'symbols', 'components', 'shelf'] as const + +test.describe('全局画布', () => { + for (const p of PAGES) { + test(`${p}.html 渲染且有内容`, async ({ page }) => { + await page.goto(`/${p}.html`) + await expect(page.locator('#root')).not.toBeEmpty() + await expect(page.locator('main')).toBeVisible() + }) + } + + test('body 大底为纸色 #FFF9F0', async ({ page }) => { + await page.goto('/index.html') + const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor) + expect(bg).toBe('rgb(255, 249, 240)') + }) + + test('全站无色彩渐变', async ({ page }) => { + await page.goto('/shelf.html') + const gradients = await page.evaluate(() => { + const bad: string[] = [] + document.querySelectorAll('*').forEach((el) => { + if (getComputedStyle(el).backgroundImage.includes('gradient')) bad.push(el.tagName) + }) + return bad + }) + expect(gradients, gradients.join(',')).toEqual([]) + }) +}) + +test.describe('CourseCard 规格契约', () => { + test.beforeEach(async ({ page }) => await page.goto('/components.html')) + + test('2px ink 描边 + 硬投影 + 纸叠三层边', async ({ page }) => { + const card = page.locator('a[href="#ch1"]').first() + await expect(card).toBeVisible() + const s = await card.evaluate((el) => { + const cs = getComputedStyle(el) + return { bw: cs.borderTopWidth, bc: cs.borderTopColor, shadow: cs.boxShadow } + }) + expect(s.bw).toBe('2px') + expect(s.bc).toBe('rgb(17, 17, 17)') + expect((s.shadow.match(/rgb|#/g) ?? []).length).toBeGreaterThanOrEqual(4) // 三层纸叠+硬投影(按色值计数) + expect(s.shadow).toContain('rgb(17, 17, 17)') + }) + + test('16:9 相框:比例、3px 黑边、内阴影、章节撞色', async ({ page }) => { + const zone = page.locator('a[href="#ch1"] .aspect-video').first() + const s = await zone.evaluate((el) => { + const cs = getComputedStyle(el) + const r = el.getBoundingClientRect() + return { ratio: r.width / r.height, bw: cs.borderTopWidth, shadow: cs.boxShadow, bg: cs.backgroundColor } + }) + expect(Math.abs(s.ratio - 16 / 9)).toBeLessThan(0.02) + expect(s.bw).toBe('3px') + expect(s.shadow).toContain('inset') + expect(s.bg).toBe('rgb(255, 107, 107)') // ch1 coral 只进相框 + }) + + test('卡头三件套:▲ + 标题 + 积木点(实心=已学)', async ({ page }) => { + const card = page.locator('a[href="#ch2"]').first() + const dots = card.locator('span[aria-label^="积木"] i') + expect(await dots.count()).toBe(4) + const filled = await dots.evaluateAll((ns) => ns.filter((n) => getComputedStyle(n).backgroundColor === 'rgb(17, 17, 17)').length) + expect(filled).toBe(2) // ch2 已学 2/4 + expect(await card.locator('svg').first()).toBeVisible() // ▲ + }) + + test('入口徽章 ①②(推荐起点,非强制)', async ({ page }) => { + await expect(page.locator('span[aria-label="推荐入口 1"]')).toBeVisible() + await expect(page.locator('span[aria-label="推荐入口 2"]')).toBeVisible() + }) +}) + +test.describe('RouteLine 规格契约', () => { + test.beforeEach(async ({ page }) => await page.goto('/shelf.html')) + + test('线形:实线先后 2.5px 无 dash;点线同族 0.1/6 圆帽', async ({ page }) => { + const specs = await page.locator('svg path').evaluateAll((ns) => + ns.map((n) => ({ w: n.getAttribute('stroke-width'), dash: n.getAttribute('stroke-dasharray'), cap: n.getAttribute('stroke-linecap') })), + ) + expect(specs.some((s) => s.w === '2.5' && !s.dash)).toBeTruthy() + expect(specs.some((s) => s.dash === '0.1 6' && s.cap === 'round')).toBeTruthy() + }) + + test('虚线相关 4:2(symbols 页线形样张)', async ({ page }) => { + await page.goto('/symbols.html') + const specs = await page.locator('svg path').evaluateAll((ns) => ns.map((n) => n.getAttribute('stroke-dasharray'))) + expect(specs).toContain('4 2') + }) + + test('站点 r6 半嵌:源骑卡右黑影外缘、目标贴左描边', async ({ page }) => { + const c1 = await page.locator('a[href="#ch1"]').first().boundingBox() + const c2 = await page.locator('a[href="#ch2"]').first().boundingBox() + const wrap = await page.locator('main div.relative').first().boundingBox() + expect(c1 && c2 && wrap).toBeTruthy() + + const stations = await page.locator('svg circle').evaluateAll((ns) => + ns.map((n) => ({ cx: +(n.getAttribute('cx') ?? 0), r: +(n.getAttribute('r') ?? 0) })).filter((s) => s.r === 6), // 排除散落 ○ 预期环 + ) + expect(stations.length).toBeGreaterThanOrEqual(4) + + const c1ShadowEdge = c1!.x - wrap!.x + c1!.width + 7 // 描边外 + 7px 黑影 + const c2Border = c2!.x - wrap!.x + expect(stations.some((s) => Math.abs(s.cx - c1ShadowEdge) <= 3), `源站点应骑黑影外缘 x≈${c1ShadowEdge}`).toBeTruthy() + expect(stations.some((s) => Math.abs(s.cx - c2Border) <= 3), `目标站点应贴左描边 x≈${c2Border}`).toBeTruthy() + }) + + test('实线端点落在水平端口(M x y H 起、H x 止)', async ({ page }) => { + const d = await page.locator('svg path[stroke-width="2.5"]').first().getAttribute('d') + expect(d).toMatch(/^M [\d.]+ [\d.]+ H/) + expect(d).toMatch(/H [\d.]+$/) + }) +}) + +test.describe('票券规格契约', () => { + test.beforeEach(async ({ page }) => await page.goto('/symbols.html')) + + test('裁切式:overflow hidden + 单一 2px 描边 + 头部色块无独立边框', async ({ page }) => { + const done = page.locator('.ticket-pk').first() + await expect(done).toBeVisible() + const s = await done.evaluate((el) => { + const cs = getComputedStyle(el) + const head = el.firstElementChild as HTMLElement + const hcs = getComputedStyle(head) + return { ov: cs.overflow, bw: cs.borderTopWidth, headBorder: hcs.borderTopWidth, headBg: hcs.backgroundColor } + }) + expect(s.ov).toBe('hidden') + expect(s.bw).toBe('2px') + expect(s.headBorder).toBe('0px') // 头部色块永不自带描边 + expect(s.headBg).not.toBe('rgba(0, 0, 0, 0)') + }) + + test('打孔伪元素在位(完成票 ×2 孔)', async ({ page }) => { + const holes = await page.locator('.ticket-pk').first().evaluate((el) => { + const b = getComputedStyle(el, '::before') + const a = getComputedStyle(el, '::after') + return [b.content, a.content].filter((c) => c !== 'none').length + }) + expect(holes).toBe(2) + }) + + test('撕线:完成=虚线、未开始=实线', async ({ page }) => { + const styles = await page.evaluate(() => + [...document.querySelectorAll('div')] + .filter((el) => String(el.className).includes('w-[216px]')) + .map((el) => getComputedStyle(el.lastElementChild as HTMLElement).borderLeftStyle), + ) + expect(styles).toContain('dashed') + expect(styles).toContain('solid') + }) +}) diff --git a/apps/design-lab/e2e/visual.spec.ts b/apps/design-lab/e2e/visual.spec.ts new file mode 100644 index 0000000..42dca10 --- /dev/null +++ b/apps/design-lab/e2e/visual.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' + +/** + * 像素级视觉回归基线——防止后续改动出现画面偏差。 + * 有意的设计变更:`yarn test:e2e -- --update-snapshots` 后提交新基线。 + */ +const CASES: [name: string, path: string][] = [ + ['index', '/index.html'], + ['colors', '/colors.html'], + ['symbols', '/symbols.html'], + ['components', '/components.html'], + ['shelf', '/shelf.html'], +] + +for (const [name, path] of CASES) { + test(`视觉基线 · ${name}`, async ({ page }) => { + await page.goto(path) + await page.evaluate(() => document.fonts.ready) + await page.waitForTimeout(300) + await expect(page).toHaveScreenshot(`${name}.png`, { + fullPage: true, + maxDiffPixelRatio: 0.01, // 允许 1% 以内的字体渲染差异 + animations: 'disabled', + }) + }) +} diff --git a/apps/design-lab/e2e/visual.spec.ts-snapshots/colors-chromium-linux.png b/apps/design-lab/e2e/visual.spec.ts-snapshots/colors-chromium-linux.png new file mode 100644 index 0000000..a2e1e10 Binary files /dev/null and b/apps/design-lab/e2e/visual.spec.ts-snapshots/colors-chromium-linux.png differ diff --git a/apps/design-lab/e2e/visual.spec.ts-snapshots/components-chromium-linux.png b/apps/design-lab/e2e/visual.spec.ts-snapshots/components-chromium-linux.png new file mode 100644 index 0000000..26bfd82 Binary files /dev/null and b/apps/design-lab/e2e/visual.spec.ts-snapshots/components-chromium-linux.png differ diff --git a/apps/design-lab/e2e/visual.spec.ts-snapshots/index-chromium-linux.png b/apps/design-lab/e2e/visual.spec.ts-snapshots/index-chromium-linux.png new file mode 100644 index 0000000..c5da908 Binary files /dev/null and b/apps/design-lab/e2e/visual.spec.ts-snapshots/index-chromium-linux.png differ diff --git a/apps/design-lab/e2e/visual.spec.ts-snapshots/shelf-chromium-linux.png b/apps/design-lab/e2e/visual.spec.ts-snapshots/shelf-chromium-linux.png new file mode 100644 index 0000000..9b27718 Binary files /dev/null and b/apps/design-lab/e2e/visual.spec.ts-snapshots/shelf-chromium-linux.png differ diff --git a/apps/design-lab/e2e/visual.spec.ts-snapshots/symbols-chromium-linux.png b/apps/design-lab/e2e/visual.spec.ts-snapshots/symbols-chromium-linux.png new file mode 100644 index 0000000..b70f66a Binary files /dev/null and b/apps/design-lab/e2e/visual.spec.ts-snapshots/symbols-chromium-linux.png differ diff --git a/apps/design-lab/package.json b/apps/design-lab/package.json index b687e95..a00e45f 100644 --- a/apps/design-lab/package.json +++ b/apps/design-lab/package.json @@ -6,13 +6,16 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:update": "playwright test --update-snapshots" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@playwright/test": "1.63.0", "@tailwindcss/vite": "^4.1.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -21,4 +24,4 @@ "typescript": "~5.6.2", "vite": "^6.0.0" } -} +} \ No newline at end of file diff --git a/apps/design-lab/playwright.config.ts b/apps/design-lab/playwright.config.ts new file mode 100644 index 0000000..8e3a0dd --- /dev/null +++ b/apps/design-lab/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * 设计系统 E2E:防止后续改动出现画面偏差。 + * - pages.spec:结构契约断言(规格级:描边/投影/比例/半嵌坐标) + * - visual.spec:整页截图基线(像素级回归) + * 截图基线在 e2e/__screenshots__/,需随代码一起提交; + * 有意的设计变更请运行 `yarn test:e2e -- --update-snapshots` 后提交新基线。 + */ +export default defineConfig({ + testDir: 'e2e', + timeout: 30_000, + fullyParallel: true, + use: { + baseURL: 'http://localhost:4490', + // 卡片尊重 prefers-reduced-motion:翻转关闭、动画停用,截图才稳定 + contextOptions: { reducedMotion: 'reduce', viewport: { width: 1280, height: 900 } }, + }, + webServer: { + command: 'yarn preview --port 4490 --strictPort', + url: 'http://localhost:4490/index.html', + reuseExistingServer: true, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}) diff --git a/apps/design-lab/src/components/Ticket.tsx b/apps/design-lab/src/components/Ticket.tsx index abc3ab1..8bcb913 100644 --- a/apps/design-lab/src/components/Ticket.tsx +++ b/apps/design-lab/src/components/Ticket.tsx @@ -6,7 +6,6 @@ export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) { return (
{/* 头部媒体块:章节色纯填充,无独立边框(由票体裁切) */}
{chapter.emoji}
@@ -21,14 +20,6 @@ export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) { > №{String(chapter.id).padStart(2, '0')}
- ) } diff --git a/apps/design-lab/src/pages/shelf/App.tsx b/apps/design-lab/src/pages/shelf/App.tsx index bfc06f0..10e5b2e 100644 --- a/apps/design-lab/src/pages/shelf/App.tsx +++ b/apps/design-lab/src/pages/shelf/App.tsx @@ -9,7 +9,7 @@ import { Bar, Cross, MissMark, Ring, Tremble, Tri, Wave } from '../../components const nodes = { 1: { id: 1, borderLeft: 40, right: 299, y: 180 }, 2: { id: 2, borderLeft: 378, right: 639, y: 108 }, - 4: { id: 4, borderLeft: 698, right: 848, y: 108 }, + 5: { id: 5, borderLeft: 698, right: 848, y: 108 }, } as const const SCATTER = [ diff --git a/apps/design-lab/src/styles/tokens.css b/apps/design-lab/src/styles/tokens.css index d0a8711..2ca5f2f 100644 --- a/apps/design-lab/src/styles/tokens.css +++ b/apps/design-lab/src/styles/tokens.css @@ -31,3 +31,22 @@ .sem-shadow { filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.9)); } + +/* 票券打孔:⌀14 圆跨边,被 overflow-hidden 裁成半圆缺口(完成票专用) */ +.ticket-pk::before, +.ticket-pk::after { + content: ''; + position: absolute; + left: 160px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-paper); + border: 2px solid var(--color-ink); +} +.ticket-pk::before { + top: -10px; +} +.ticket-pk::after { + bottom: -10px; +} diff --git a/apps/design-lab/test-results/.last-run.json b/apps/design-lab/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/apps/design-lab/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ad34917..a2e3cbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3172,6 +3172,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:1.63.0": + version: 1.63.0 + resolution: "@playwright/test@npm:1.63.0" + dependencies: + playwright: "npm:1.63.0" + bin: + playwright: cli.js + checksum: 10/b5e561b8f11488b8062b98c5cf975740f48ae7e9b455fb7152e0fb90d8e13f20c3c3ae6a15833655364246b99f5a46f6601daa5a9eef01de5004c8be7e2b3af3 + languageName: node + linkType: hard + "@pnpm/config.env-replace@npm:^1.1.0": version: 1.1.0 resolution: "@pnpm/config.env-replace@npm:1.1.0" @@ -3447,6 +3458,7 @@ __metadata: version: 0.0.0-use.local resolution: "@stack/design-lab@workspace:apps/design-lab" dependencies: + "@playwright/test": "npm:1.63.0" "@tailwindcss/vite": "npm:^4.1.0" "@types/react": "npm:^18.3.12" "@types/react-dom": "npm:^18.3.1" @@ -9905,6 +9917,26 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.63.0": + version: 1.63.0 + resolution: "playwright-core@npm:1.63.0" + bin: + playwright-core: cli.js + checksum: 10/d0f7c70786f5e08aa7553a77ba28c07427086e323ae8c4126a647debd8181966dc11d6f8fe1462f2a776ad60c95451b08f01fbee17b11e69a5bd9a2070fe75a6 + languageName: node + linkType: hard + +"playwright@npm:1.63.0": + version: 1.63.0 + resolution: "playwright@npm:1.63.0" + dependencies: + playwright-core: "npm:1.63.0" + bin: + playwright: cli.js + checksum: 10/ec87c22888148603a8955bc2a092f416455af931fe53925b9143ce333b83e42a6d8c159c00a4f2d232b27100cf3eadd93cec8a6e5c4e12c1c00663d2034dd515 + languageName: node + linkType: hard + "postcss-attribute-case-insensitive@npm:^7.0.1": version: 7.0.1 resolution: "postcss-attribute-case-insensitive@npm:7.0.1"