fix: 票券挖空补全三件套——阴影改 wrapper drop-shadow 跟随挖空轮廓(mask 默认 border-box 裁切会整体吃掉 box-shadow),孔缘补 .pk-arc ink 半圆弧线;E2E 统一契约改双机制同配方断言
No files matched your search
@@ -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]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |