diff --git a/apps/design-lab/e2e/pages.spec.ts b/apps/design-lab/e2e/pages.spec.ts index b53f08b..88ff1e3 100644 --- a/apps/design-lab/e2e/pages.spec.ts +++ b/apps/design-lab/e2e/pages.spec.ts @@ -52,18 +52,20 @@ test.describe('CourseCard 规格契约', () => { expect(s.shadow).toContain('rgb(17, 17, 17) 5px 5px 0px 0px') }) - test('基础组件投影统一:同一 token 复用,不得各写各的', async ({ page }) => { - const { kinds, shadows } = await page.evaluate(() => { + test('基础组件投影统一:同一配方复用,不得各写各的', async ({ page }) => { + const { kinds, recipes } = await page.evaluate(() => { const els = [...document.querySelectorAll('[data-comp]')] + // 两种机制同一配方:box-shadow(常规卡)与 filter: drop-shadow(票券——跟随挖空轮廓) + const recipe = (cs: CSSStyleDeclaration) => (cs.filter !== 'none' ? cs.filter : cs.boxShadow) return { kinds: [...new Set(els.map((el) => el.dataset.comp))].sort(), - shadows: els.map((el) => getComputedStyle(el).boxShadow), + recipes: els.map((el) => recipe(getComputedStyle(el))), } }) expect(kinds).toEqual(['assign', 'course-card', 'entry-card', 'ticket']) // 四类基础组件都有钩子 - expect(shadows.length).toBeGreaterThanOrEqual(6) - expect(new Set(shadows).size).toBe(1) // 计算值完全一致=样式复用 - expect(shadows[0]).toContain('rgb(17, 17, 17) 5px 5px 0px 0px') + expect(recipes.length).toBeGreaterThanOrEqual(6) + recipes.forEach((r) => expect(r).toContain('rgb(17, 17, 17) 5px 5px')) // 唯一配方 --shadow-hard / --drop-shadow-hard + recipes.forEach((r) => expect(r.split('rgb(17, 17, 17)').length - 1).toBe(1)) // 每卡只出一份阴影 }) test('16:9 相框:比例、3px 黑边、内阴影、章节撞色', async ({ page }) => { @@ -155,13 +157,40 @@ test.describe('票券规格契约', () => { expect(s.headBg).not.toBe('rgba(0, 0, 0, 0)') }) - test('打孔差集在位(mask 两端 1/4 圆,完成票)', async ({ page }) => { - const mask = await page.locator('.ticket-pk').first().evaluate((el) => { + test('打孔三件套:mask 差集挖空 + 孔缘弧线 + 阴影跟随轮廓', async ({ page }) => { + const done = page.locator('.ticket-pk').first() + const mask = await done.evaluate((el) => { const cs = getComputedStyle(el) return cs.maskImage || cs.webkitMaskImage || 'none' }) expect(mask).toContain('radial-gradient') expect(mask.split('radial-gradient').length - 1).toBe(2) // 上下各一孔 + // 阴影在 wrapper 以 drop-shadow 生效——跟随挖空后的 alpha 轮廓(box-shadow 会被 mask 裁掉) + const f = await done.evaluate((el) => { + const w = el.parentElement as HTMLElement + return { filter: getComputedStyle(w).filter, innerShadow: getComputedStyle(el).boxShadow } + }) + expect(f.filter).toBe('drop-shadow(rgb(17, 17, 17) 5px 5px 0px)') + expect(f.innerShadow).toBe('none') + // 孔缘弧线:2px ink 半圆环,clip 只留票内一半,圆心骑撕线两端(hole-x=172.75) + const arcs = await page.evaluate(() => { + const done = document.querySelector('.ticket-pk') as HTMLElement + const w = done.parentElement as HTMLElement + const wx = w.getBoundingClientRect().x + return [...w.querySelectorAll('.pk-arc')].map((a) => { + const cs = getComputedStyle(a) + const r = a.getBoundingClientRect() + return { clip: cs.clipPath, bw: cs.borderTopWidth, bc: cs.borderTopColor, cx: r.x + r.width / 2 - wx } + }) + }) + expect(arcs).toHaveLength(2) + arcs.forEach((a) => { + expect(a.bw).toBe('2px') + expect(a.bc).toBe('rgb(17, 17, 17)') + expect(Math.abs(a.cx - 172.75)).toBeLessThan(0.5) + }) + expect(arcs[0].clip).toBe('inset(50% 0px 0px)') + expect(arcs[1].clip).toBe('inset(0px 0px 50%)') // 未开始票不带 mask const plain = await page.evaluate(() => { const t = [...document.querySelectorAll('div')].filter((d) => String(d.className).includes('w-[216px]') && !String(d.className).includes('ticket-pk'))[0] 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 index 4dc7ac4..3325b8f 100644 Binary files a/apps/design-lab/e2e/visual.spec.ts-snapshots/components-chromium-linux.png 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/symbols-chromium-linux.png b/apps/design-lab/e2e/visual.spec.ts-snapshots/symbols-chromium-linux.png index 472843a..a417ebe 100644 Binary files a/apps/design-lab/e2e/visual.spec.ts-snapshots/symbols-chromium-linux.png and b/apps/design-lab/e2e/visual.spec.ts-snapshots/symbols-chromium-linux.png differ diff --git a/apps/design-lab/src/components/Ticket.tsx b/apps/design-lab/src/components/Ticket.tsx index 5980fa4..dd11741 100644 --- a/apps/design-lab/src/components/Ticket.tsx +++ b/apps/design-lab/src/components/Ticket.tsx @@ -1,13 +1,14 @@ /** 票券 —— 只表达完成状态:完成=沿撕线打孔(半圆缺口),未开始=实线无孔。 - * 裁切式实现:头部色块无独立描边,由票体 overflow-hidden 裁出单一描边圆角。 */ + * 外层 wrapper 出阴影(drop-shadow 跟随挖空后的轮廓,box-shadow 会被 mask 裁掉); + * 内层票体裁切式:头部色块无独立描边,由票体 overflow-hidden 裁出单一描边圆角。 */ import type { Chapter } from '../lib/types' export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) { return ( -
+
+
{/* 头部媒体块:章节色纯填充,无独立边框(由票体裁切) */}
{chapter.emoji}
@@ -21,6 +22,14 @@ export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) { > №{String(chapter.id).padStart(2, '0')}
+
+ {/* 孔缘弧线:mask 只减形不补线,弧线补上挖空边缘的 ink 描边 */} + {done && ( + <> + + + + )}
) } diff --git a/apps/design-lab/src/styles/tokens.css b/apps/design-lab/src/styles/tokens.css index 2ae6ce8..76061f5 100644 --- a/apps/design-lab/src/styles/tokens.css +++ b/apps/design-lab/src/styles/tokens.css @@ -19,6 +19,8 @@ /* 统一硬投影——唯一投影配方,所有基础组件复用(rest / 拿起) */ --shadow-hard: 5px 5px 0 var(--color-ink); --shadow-hard-lift: 8px 8px 0 var(--color-ink); + /* 同配方的 drop-shadow 形态:给挖空轮廓用(票券)——box-shadow 会被 mask 裁掉 */ + --drop-shadow-hard: 5px 5px 0 var(--color-ink); /* 章节色别名 */ --color-ch1: #ff6b6b; @@ -29,17 +31,39 @@ --color-ch6: #a78bfa; } -/* 票券打孔:两端各以 1/4 圆做差集(完成票专用)。 - * mask 硬停只做形状剪切(非色彩渐变):孔真正穿透票体——露出页面底色, - * 描边与硬投影在孔处真实断开,无需伪元素盖色近似。 */ -.ticket-pk { +/* 票券打孔(完成票专用),三件分工: + * 挖空 = 票体 mask 差集(两端 1/4 圆硬停剪切,孔穿透露出页面底色); + * 阴影 = 外层 wrapper drop-shadow-hard——drop-shadow 跟随 alpha 轮廓, + * 孔挖到哪阴影咬到哪(box-shadow 会被 mask 的 border-box 裁切整体吃掉,不可用); + * 孔缘 = .pk-arc 半圆环补 ink 边线(mask 只减形不补线),圆心骑撕线两端。 */ +.tk-pk { --hole-x: 172.75px; /* 撕线中心:216 − 右描边 2 − stub 42 + 撕线半宽 */ +} +.ticket-pk { -webkit-mask-image: - radial-gradient(circle 7px at var(--hole-x) 0, transparent 6.5px, #000 7px), - radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6.5px, #000 7px); + radial-gradient(circle 7px at var(--hole-x) 0, transparent 6px, #000 6.5px), + radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6px, #000 6.5px); -webkit-mask-composite: source-in; mask-image: - radial-gradient(circle 7px at var(--hole-x) 0, transparent 6.5px, #000 7px), - radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6.5px, #000 7px); + radial-gradient(circle 7px at var(--hole-x) 0, transparent 6px, #000 6.5px), + radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6px, #000 6.5px); mask-composite: intersect; } +/* 孔缘弧线:16px 圆环(描边带 r6–8),clip-path 只留票内一半,不进阴影轮廓 */ +.pk-arc { + position: absolute; + left: calc(var(--hole-x) - 8px); + width: 16px; + height: 16px; + border: 2px solid var(--color-ink); + border-radius: 9999px; + pointer-events: none; +} +.pk-arc-t { + top: -8px; + clip-path: inset(50% 0 0 0); +} +.pk-arc-b { + bottom: -8px; + clip-path: inset(0 0 50% 0); +} diff --git a/docs/screenshots/design-lab/pr_components.png b/docs/screenshots/design-lab/pr_components.png index 4dc7ac4..3325b8f 100644 Binary files a/docs/screenshots/design-lab/pr_components.png and b/docs/screenshots/design-lab/pr_components.png differ diff --git a/docs/screenshots/design-lab/pr_symbols.png b/docs/screenshots/design-lab/pr_symbols.png index 472843a..a417ebe 100644 Binary files a/docs/screenshots/design-lab/pr_symbols.png and b/docs/screenshots/design-lab/pr_symbols.png differ