fix: 票券挖空补全三件套——阴影改 wrapper drop-shadow 跟随挖空轮廓(mask 默认 border-box 裁切会整体吃掉 box-shadow),孔缘补 .pk-arc ink 半圆弧线;E2E 统一契约改双机制同配方断言

This commit is contained in:
cqh963852 committed 2026-10-06 14:25:35 +08:00
1 parent b528b5396a
commit e59db8c199
7 files changed
+83 -21

No files matched your search

+37 -8
View File
@@ -52,18 +52,20 @@ test.describe('CourseCard 规格契约', () => {
expect(s.shadow).toContain('rgb(17, 17, 17) 5px 5px 0px 0px') expect(s.shadow).toContain('rgb(17, 17, 17) 5px 5px 0px 0px')
}) })
test('基础组件投影统一:同一 token 复用,不得各写各的', async ({ page }) => { test('基础组件投影统一:同一配方复用,不得各写各的', async ({ page }) => {
const { kinds, shadows } = await page.evaluate(() => { const { kinds, recipes } = await page.evaluate(() => {
const els = [...document.querySelectorAll('[data-comp]')] const els = [...document.querySelectorAll('[data-comp]')]
// 两种机制同一配方:box-shadow(常规卡)与 filter: drop-shadow(票券——跟随挖空轮廓)
const recipe = (cs: CSSStyleDeclaration) => (cs.filter !== 'none' ? cs.filter : cs.boxShadow)
return { return {
kinds: [...new Set(els.map((el) => el.dataset.comp))].sort(), 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(kinds).toEqual(['assign', 'course-card', 'entry-card', 'ticket']) // 四类基础组件都有钩子
expect(shadows.length).toBeGreaterThanOrEqual(6) expect(recipes.length).toBeGreaterThanOrEqual(6)
expect(new Set(shadows).size).toBe(1) // 计算值完全一致=样式复用 recipes.forEach((r) => expect(r).toContain('rgb(17, 17, 17) 5px 5px')) // 唯一配方 --shadow-hard / --drop-shadow-hard
expect(shadows[0]).toContain('rgb(17, 17, 17) 5px 5px 0px 0px') recipes.forEach((r) => expect(r.split('rgb(17, 17, 17)').length - 1).toBe(1)) // 每卡只出一份阴影
}) })
test('16:9 相框:比例、3px 黑边、内阴影、章节撞色', async ({ page }) => { test('16:9 相框:比例、3px 黑边、内阴影、章节撞色', async ({ page }) => {
@@ -155,13 +157,40 @@ test.describe('票券规格契约', () => {
expect(s.headBg).not.toBe('rgba(0, 0, 0, 0)') expect(s.headBg).not.toBe('rgba(0, 0, 0, 0)')
}) })
test('打孔差集在位(mask 两端 1/4 圆,完成票)', async ({ page }) => { test('打孔三件套:mask 差集挖空 + 孔缘弧线 + 阴影跟随轮廓', async ({ page }) => {
const mask = await page.locator('.ticket-pk').first().evaluate((el) => { const done = page.locator('.ticket-pk').first()
const mask = await done.evaluate((el) => {
const cs = getComputedStyle(el) const cs = getComputedStyle(el)
return cs.maskImage || cs.webkitMaskImage || 'none' return cs.maskImage || cs.webkitMaskImage || 'none'
}) })
expect(mask).toContain('radial-gradient') expect(mask).toContain('radial-gradient')
expect(mask.split('radial-gradient').length - 1).toBe(2) // 上下各一孔 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 // 未开始票不带 mask
const plain = await page.evaluate(() => { 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] const t = [...document.querySelectorAll('div')].filter((d) => String(d.className).includes('w-[216px]') && !String(d.className).includes('ticket-pk'))[0]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 179 KiB

+14 -5
View File
@@ -1,13 +1,14 @@
/** 票券 —— 只表达完成状态:完成=沿撕线打孔(半圆缺口),未开始=实线无孔。 /** 票券 —— 只表达完成状态:完成=沿撕线打孔(半圆缺口),未开始=实线无孔。
* 裁切式实现:头部色块无独立描边,由票体 overflow-hidden 裁出单一描边圆角。 */ * 外层 wrapper 出阴影(drop-shadow 跟随挖空后的轮廓,box-shadow 会被 mask 裁掉);
* 内层票体裁切式:头部色块无独立描边,由票体 overflow-hidden 裁出单一描边圆角。 */
import type { Chapter } from '../lib/types' import type { Chapter } from '../lib/types'
export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) { export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) {
return ( return (
<div <div data-comp="ticket" className={`w-fit drop-shadow-hard ${done ? 'tk-pk' : ''}`}>
data-comp="ticket" <div
className={`relative flex w-[216px] items-stretch overflow-hidden rounded-xl border-2 border-ink bg-paper shadow-hard ${done ? 'ticket-pk' : ''}`} className={`relative flex w-[216px] items-stretch overflow-hidden rounded-xl border-2 border-ink bg-paper ${done ? 'ticket-pk' : ''}`}
> >
{/* 头部媒体块:章节色纯填充,无独立边框(由票体裁切) */} {/* 头部媒体块:章节色纯填充,无独立边框(由票体裁切) */}
<div className="flex w-14 shrink-0 items-center justify-center text-[22px]" style={{ background: chapter.color }}>{chapter.emoji}</div> <div className="flex w-14 shrink-0 items-center justify-center text-[22px]" style={{ background: chapter.color }}>{chapter.emoji}</div>
<div className="flex-1 py-2 pl-2.5 pr-2"> <div className="flex-1 py-2 pl-2.5 pr-2">
@@ -21,6 +22,14 @@ export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) {
> >
№{String(chapter.id).padStart(2, '0')} №{String(chapter.id).padStart(2, '0')}
</div> </div>
</div>
{/* 孔缘弧线:mask 只减形不补线,弧线补上挖空边缘的 ink 描边 */}
{done && (
<>
<i className="pk-arc pk-arc-t" aria-hidden />
<i className="pk-arc pk-arc-b" aria-hidden />
</>
)}
</div> </div>
) )
} }
+32 -8
View File
@@ -19,6 +19,8 @@
/* 统一硬投影——唯一投影配方,所有基础组件复用(rest / 拿起) */ /* 统一硬投影——唯一投影配方,所有基础组件复用(rest / 拿起) */
--shadow-hard: 5px 5px 0 var(--color-ink); --shadow-hard: 5px 5px 0 var(--color-ink);
--shadow-hard-lift: 8px 8px 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; --color-ch1: #ff6b6b;
@@ -29,17 +31,39 @@
--color-ch6: #a78bfa; --color-ch6: #a78bfa;
} }
/* 票券打孔:两端各以 1/4 圆做差集(完成票专用)。 /* 票券打孔(完成票专用),三件分工:
* mask 硬停只做形状剪切(非色彩渐变):孔真正穿透票体——露出页面底色, * 挖空 = 票体 mask 差集(两端 1/4 圆硬停剪切,孔穿透露出页面底色);
* 描边与硬投影在孔处真实断开,无需伪元素盖色近似。 */ * 阴影 = 外层 wrapper drop-shadow-hard——drop-shadow 跟随 alpha 轮廓,
.ticket-pk { * 孔挖到哪阴影咬到哪(box-shadow 会被 mask 的 border-box 裁切整体吃掉,不可用);
* 孔缘 = .pk-arc 半圆环补 ink 边线(mask 只减形不补线),圆心骑撕线两端。 */
.tk-pk {
--hole-x: 172.75px; /* 撕线中心:216 − 右描边 2 − stub 42 + 撕线半宽 */ --hole-x: 172.75px; /* 撕线中心:216 − 右描边 2 − stub 42 + 撕线半宽 */
}
.ticket-pk {
-webkit-mask-image: -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) 0, transparent 6px, #000 6.5px),
radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6.5px, #000 7px); radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6px, #000 6.5px);
-webkit-mask-composite: source-in; -webkit-mask-composite: source-in;
mask-image: mask-image:
radial-gradient(circle 7px at var(--hole-x) 0, 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 6.5px, #000 7px); radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6px, #000 6.5px);
mask-composite: intersect; 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);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 179 KiB