feat(h5): refine report layout, add finger-following tabs and optimize images

This commit is contained in:
xsl
2026-10-12 22:36:44 +08:00
parent bcf198ca6f
commit 63c9e065a9
22 changed files with 2027 additions and 1535 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+26 -11
View File
@@ -75,20 +75,12 @@
/* Fourth feedback adds a fast one-second round trip to original PSD pixels.
The stage/mask share one contain transform, including short/landscape screens. */
#p-analyzing { background: #000; overflow: hidden; }
.scan-stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(100vw, 480px, calc(100dvh * 1080 / 2091)); height: min(100dvh, calc(min(100vw, 480px) * 2091 / 1080)); overflow: hidden; }
.scan-stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(100vw, 480px, calc(100dvh * 1080 / 2091)); height: min(100dvh, calc(min(100vw, 480px) * 2091 / 1080)); }
@supports not (height: 100dvh) { .scan-stage { width: min(100vw, 480px, calc(100vh * 1080 / 2091)); height: min(100vh, calc(min(100vw, 480px) * 2091 / 1080)); } }
.scan-artwork { width: 100%; height: 100%; object-fit: contain; }
.scan-face { position: absolute; inset: 0; background: #111; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; overflow: hidden; }
/* Same centered cover crop as the camera; never stretch the captured JPEG.
The PSD mask clips the real face, covering the original white placeholder. */
.scan-photo { position: absolute; left: 50%; top: 50%; width: min(100vw, 480px); height: 100vh; height: 100dvh; max-width: none; object-fit: cover; transform: translate(-50%, -50%); }
.scan-photo.mirror { transform: translate(-50%, -50%) scaleX(-1); }
.scan-photo:not([src]) { visibility: hidden; }
.scan-face { position: absolute; inset: 0; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; overflow: hidden; }
.scan-beam { position: absolute; left: 0; right: 0; top: 20%; height: 18%; background: linear-gradient(to bottom, #F5F078 0%, #F5F078 35%, rgba(245,240,120,0) 100%); }
#p-analyzing:not(.scan-artwork-ready) .scan-artwork { visibility: hidden; }
#p-analyzing:not(.scan-artwork-ready) .scan-face { -webkit-mask-image: none !important; mask-image: none !important; }
#p-analyzing:not(.scan-artwork-ready) .scan-photo { opacity: .6; }
#p-analyzing:not(.scan-artwork-ready) .scan-beam { visibility: hidden; }
#p-analyzing:not(.scan-artwork-ready) .scan-stage { visibility: hidden; }
.scan-running .scan-beam { animation: report-scan var(--scan-cycle, 1000ms) ease-in-out infinite; }
@keyframes report-scan { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(166%); } }
.scan-running:not(.scan-artwork-ready) .scan-status { animation: report-scan-fallback var(--scan-cycle, 1000ms) ease-in-out infinite; }
@@ -114,3 +106,26 @@
#dims-note::before { left: 12px; top: 10px; bottom: 10px; }
#dims-note .dims-note-l1 { font-size: 12px; line-height: 1.5; }
#dims-note .dims-note-l2 { font-size: 11px; line-height: 1.5; margin-top: 3px; }
/* Labels share a top baseline outside the dial; center the score digits, not
digits plus unit, inside the yellow ring. */
.sum-score, .sum-age { flex-direction: column; align-items: center; justify-content: flex-start; gap: calc(6 * var(--u)); }
.sum-age { align-self: stretch; }
.sum-score > .stat-label, .sum-age > .stat-label { font-size: 12px; line-height: 1.5; }
.score-dial, .age-dial { position: relative; width: calc(108 * var(--u)); height: calc(108 * var(--u)); }
.age-dial { display: flex; align-items: center; justify-content: center; }
.age-dial .stat-age { margin-top: 0; }
#stat-score { position: relative; }
#stat-score b { display: block; letter-spacing: 0; }
#stat-score i { position: absolute; left: 100%; bottom: 3px; margin-left: 2px; }
.card-care .card-heading { text-align: left; }
.detail-viewport { overflow: hidden; touch-action: pan-y; transition: height .32s ease; }
.detail-viewport.dragging { transition: none; }
.detail-panels { display: flex; align-items: flex-start; transition: transform .32s cubic-bezier(.22,.61,.36,1); }
.detail-panels.dragging { transition: none; will-change: transform; }
.detail-panels .dpanel { display: block; flex: 0 0 100%; min-width: 0; padding-top: calc(10 * var(--u)); animation: none; }
.detail-panels .ditem-img { -webkit-user-drag: none; }
@media (prefers-reduced-motion: reduce) {
.detail-panels, .detail-viewport { transition-duration: .01ms; }
}
+18 -21
View File
@@ -173,18 +173,20 @@
<!-- 总分卡:左环形综合评分 / 右肌肤年龄 + 与实际年龄对比 -->
<div class="card card-summary">
<div class="sum-score">
<svg class="ring" viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-bg" cx="60" cy="60" r="52"/>
<circle class="ring-fg" id="ring-fg" cx="60" cy="60" r="52"/>
</svg>
<div class="ring-center">
<span class="stat-value" id="stat-score"><b>--</b><i>分</i></span>
<span class="stat-label">综合评分</span>
<span class="stat-label">综合评分</span>
<div class="score-dial">
<svg class="ring" viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-bg" cx="60" cy="60" r="52"/>
<circle class="ring-fg" id="ring-fg" cx="60" cy="60" r="52"/>
</svg>
<div class="ring-center">
<span class="stat-value" id="stat-score"><b>--</b><i>分</i></span>
</div>
</div>
</div>
<div class="sum-age">
<span class="stat-label">生物可视年龄</span>
<span class="stat-value stat-age" id="stat-age"><b>--</b><i>岁</i></span>
<div class="age-dial"><span class="stat-value stat-age" id="stat-age"><b>--</b><i>岁</i></span></div>
<span class="age-pill" id="stat-age-note" hidden></span>
</div>
@@ -202,7 +204,7 @@
<h2 class="dimension-title">紧塑力</h2>
<div class="card card-detail">
<div class="detail-tabs"><button class="dtab active" data-tab="0"><span class="dtab-tx">轮廓</span></button><button class="dtab" data-tab="1"><span class="dtab-tx">皱纹</span></button></div>
<div class="detail-panels">
<div class="detail-viewport"><div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem"><div class="viz-frame"><img id="ditem-img1" class="ditem-img" alt="轮廓检测图" hidden><span class="viz-missing">本次未获取轮廓检测图</span></div>
<div class="ditem-text"><p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p></div></div>
@@ -211,13 +213,12 @@
<div class="ditem"><div class="viz-frame"><img id="ditem-img2" class="ditem-img" alt="皱纹检测图" hidden><span class="viz-missing">本次未获取皱纹检测图</span></div>
<div class="ditem-text"><p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p></div></div>
</div>
</div>
</div></div>
</div>
<h2 class="dimension-title">透亮感</h2>
<div class="card card-detail">
<div class="detail-tabs"><button class="dtab active" data-tab="0"><span class="dtab-tx">肤色</span></button><button class="dtab" data-tab="1"><span class="dtab-tx">平滑度</span></button><button class="dtab" data-tab="2"><span class="dtab-tx">水分</span></button></div>
<div class="detail-panels">
<div class="detail-viewport"><div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem"><div class="viz-frame"><img id="viz-tone" class="ditem-img" alt="肤色检测图" hidden><span class="viz-missing">本次未获取肤色检测图</span></div>
<div class="ditem-text"><p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p></div></div>
@@ -231,13 +232,12 @@
<div class="ditem"><div class="viz-frame"><img id="viz-water" class="ditem-img" alt="水分检测图" hidden><span class="viz-missing">本次未获取水分检测图</span></div>
<div class="ditem-text"><p class="ditem-para" id="tx-water">检测完成后显示分析结果。</p></div></div>
</div>
</div>
</div></div>
</div>
<h2 class="dimension-title">稳韧力</h2>
<div class="card card-detail">
<div class="detail-tabs"><button class="dtab active" data-tab="0"><span class="dtab-tx">泛红</span></button><button class="dtab" data-tab="1"><span class="dtab-tx">痘痘</span></button></div>
<div class="detail-panels">
<div class="detail-viewport"><div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem"><div class="viz-frame"><img id="viz-sens" class="ditem-img" alt="泛红检测图" hidden><span class="viz-missing">本次未获取泛红检测图</span></div>
<div class="ditem-text"><p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p></div></div>
@@ -246,9 +246,8 @@
<div class="ditem"><div class="viz-frame"><img id="viz-acne" class="ditem-img" alt="痘痘检测图" hidden><span class="viz-missing">本次未获取痘痘检测图</span></div>
<div class="ditem-text"><p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p></div></div>
</div>
</div>
</div></div>
</div>
<p class="vm-notes" id="vm-notes" style="display:none"></p>
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。</p>
@@ -457,10 +456,7 @@
Not camera controls: no action attributes, focus targets or file inputs. -->
<div class="scan-stage" aria-hidden="true">
<img id="scan-artwork" class="scan-artwork" data-src="assets/img/ui-20261012/scan-base.webp" width="1080" height="2091" alt="" draggable="false">
<div id="scan-face" class="scan-face" data-mask="assets/img/ui-20261012/scan-face-mask.png">
<img id="scan-photo" class="scan-photo" alt="" draggable="false">
<i class="scan-beam"></i>
</div>
<div id="scan-face" class="scan-face" data-mask="assets/img/ui-20261012/scan-face-mask.png"><i class="scan-beam"></i></div>
</div>
<p class="scan-status" role="status">检测中…</p>
<button class="scan-cancel" type="button" data-action="cancel-analysis">取消</button>
@@ -491,6 +487,7 @@ if (/[?&]mock=1/.test(location.search)) document.write('<script src="assets/mock
<script src="js/report-ui.js" defer></script>
<script src="js/account-client.js" defer></script>
<script src="js/profile-store.js" defer></script>
<script src="js/swipe-track.js" defer></script>
<script src="js/camera-geometry.js" defer></script>
<script src="js/app.js" defer></script>
</body>
+47 -130
View File
@@ -126,39 +126,29 @@
});
/* ─── 报告页状态重置(各维度卡回到第一个子 Tab、产品横滑归零) ─── */
function setDetailTab(card, idx) {
var detailSwipes = new WeakMap();
function setDetailTab(card, idx, instant) {
card.querySelectorAll('.dtab').forEach(function (t) {
var selected = t.dataset.tab === idx;
t.classList.toggle('active', selected);
t.setAttribute('aria-selected', String(selected));
t.setAttribute('tabindex', selected ? '0' : '-1');
});
card.querySelectorAll('.dpanel').forEach(function (p) { p.classList.toggle('active', p.dataset.panel === idx); });
card.querySelectorAll('.dpanel').forEach(function (p) {
var selected = p.dataset.panel === idx;
p.classList.toggle('active', selected); p.setAttribute('aria-hidden', String(!selected)); p.inert = !selected;
});
var detail = detailSwipes.get(card);
if (detail) { detail.swipe.go(Number(idx), instant); detail.height(Number(idx)); }
}
function resetReport() {
document.querySelectorAll('.card-detail').forEach(function (c) { setDetailTab(c, '0'); });
document.querySelectorAll('.card-detail').forEach(function (c) { setDetailTab(c, '0', true); });
var ps = $('#prod-scroll');
if (ps) ps.scrollLeft = 0;
}
/* ─── 弹窗管理 ─── */
var SCAN_CYCLE_MS = 1000, scanCycle = null, scanMask = null;
function setScanPhoto(source, mirrored) {
var image = $('#scan-photo');
image.classList.toggle('mirror', !!mirrored);
if (image.getAttribute('src') === source) return;
image.onload = function () {
// A delayed JPEG decode must still get one complete visible scan cycle.
if ($('#p-analyzing').classList.contains('show')) restartScanCycle();
};
image.onerror = function () { image.removeAttribute('src'); };
image.src = source;
}
function clearScanPhoto() {
var image = $('#scan-photo');
image.onload = image.onerror = null;
image.removeAttribute('src'); image.classList.remove('mirror');
}
function restartScanCycle() {
if (!scanCycle || scanCycle.done) return;
var overlay = $('#p-analyzing'), cycle = scanCycle;
@@ -222,7 +212,7 @@
}
function closeOverlay(id) {
$('#' + id).classList.remove('show');
if (id === 'p-analyzing') { stopScanCycle(); clearScanPhoto(); }
if (id === 'p-analyzing') stopScanCycle();
}
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
@@ -278,7 +268,7 @@
{ img: 'assets/img/ui-20261009/skin-3-oily.png', name: '油性肌肤', desc: '全脸易出油,容易泛油光、毛孔明显' },
{ img: 'assets/img/ui-20261009/skin-4-mixedoily.png', name: '混合偏油肌肤', desc: 'T区易出油,两颊相对清爽或轻微干燥' }
];
var skinIndex = 0;
var skinIndex = 0, skinSwipe;
var track = $('#skin-track'), dotsBox = $('#skin-dots');
SKINS.forEach(function (s, i) {
@@ -297,8 +287,8 @@
function goSkin(i, persist) {
skinIndex = Math.max(0, Math.min(SKINS.length - 1, i));
track.classList.remove('dragging');
track.style.transform = 'translateX(-' + skinIndex * 100 + '%)';
if (skinSwipe) skinSwipe.go(skinIndex);
else track.style.transform = 'translateX(-' + skinIndex * 100 + '%)';
dotsBox.querySelectorAll('i').forEach(function (d, k) {
d.classList.toggle('on', k === skinIndex);
});
@@ -312,74 +302,12 @@
});
goSkin(0);
// Follow the finger immediately, then snap with distance/flick intent.
// pan-y and direction locking preserve normal vertical page scrolling.
var drag = { id: null, horizontal: false, vertical: false, suppressUntil: 0 };
var carousel = $('#skin-carousel');
function cancelSkinDrag() {
var id = drag.id;
drag.id = null; drag.horizontal = false; drag.vertical = false;
try { if (id != null && carousel.hasPointerCapture(id)) carousel.releasePointerCapture(id); } catch (_) {}
goSkin(skinIndex, false);
}
carousel.addEventListener('pointerdown', function (e) {
if (drag.id != null || e.isPrimary === false || e.button !== 0 || !carousel.clientWidth) return;
try { carousel.setPointerCapture(e.pointerId); } catch (err) { /* 非活动指针时忽略,退化为普通监听 */ }
drag.id = e.pointerId;
drag.startX = e.clientX;
drag.startY = e.clientY;
drag.offset = track.getBoundingClientRect().left - carousel.getBoundingClientRect().left;
drag.width = carousel.clientWidth;
drag.lastX = e.clientX; drag.lastAt = e.timeStamp; drag.velocity = 0;
drag.horizontal = false; drag.vertical = false;
});
carousel.addEventListener('pointermove', function (e) {
if (e.pointerId !== drag.id || drag.vertical) return;
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
if (!drag.horizontal) {
if (Math.abs(dy) > 8 && Math.abs(dy) >= Math.abs(dx)) { drag.vertical = true; return; }
if (Math.abs(dx) <= 8 || Math.abs(dx) <= Math.abs(dy)) return;
drag.horizontal = true; track.classList.add('dragging');
}
if (e.cancelable) e.preventDefault();
var elapsed = e.timeStamp - drag.lastAt;
if (elapsed > 0) drag.velocity = (e.clientX - drag.lastX) / elapsed;
drag.lastX = e.clientX; drag.lastAt = e.timeStamp;
var offset = drag.offset + dx, min = -(SKINS.length - 1) * drag.width;
if (offset > 0) offset *= .25;
else if (offset < min) offset = min + (offset - min) * .25;
track.style.transform = 'translate3d(' + offset + 'px,0,0)';
});
carousel.addEventListener('pointerup', function (e) {
if (e.pointerId !== drag.id) return;
var dx = e.clientX - drag.startX;
if (drag.horizontal) {
var start = Math.round(-drag.offset / drag.width), target = start;
var speed = e.timeStamp - drag.lastAt > 100 ? 0 : drag.velocity;
if (Math.abs(dx) >= Math.min(50, drag.width * .2) || (Math.abs(dx) > 12 && Math.abs(speed) > .4)) {
target = Math.round(-(drag.offset + dx) / drag.width);
if (target === start) target += (Math.abs(speed) > .4 ? speed : dx) < 0 ? 1 : -1;
}
goSkin(target);
drag.suppressUntil = Date.now() + 350;
}
drag.id = null;
drag.horizontal = false; drag.vertical = false;
});
carousel.addEventListener('pointercancel', function (e) {
if (e.pointerId === drag.id) cancelSkinDrag();
});
carousel.addEventListener('lostpointercapture', function (e) {
if (e.pointerId === drag.id) cancelSkinDrag();
});
carousel.addEventListener('dragstart', function (e) { e.preventDefault(); });
carousel.addEventListener('click', function (e) {
if (Date.now() < drag.suppressUntil) { e.preventDefault(); e.stopPropagation(); }
}, true);
skinSwipe = SwipeTrack.create(carousel, track, { count: SKINS.length, onSelect: goSkin });
function cancelSkinDrag() { skinSwipe.cancel(); }
carousel.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); goSkin(skinIndex + (e.key === 'ArrowRight' ? 1 : -1)); }
});
window.addEventListener('resize', cancelSkinDrag);
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 mobile-select 底部滚轮(选中值写入同名 hidden input) ─── */
var NOW_Y = new Date().getFullYear();
@@ -461,7 +389,7 @@
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
var camVideo = $('#cam-video'), camFile = $('#cam-file'), albumFile = $('#album-file');
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
var photos = [], scanPhotoMirrored = false; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
var photos = []; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
var captureDiagnostics = [], holdState = null;
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
@@ -587,7 +515,7 @@
hideCamStatus();
shutterLock = false;
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
onPhotoCaptured(url, false); // Files already have their own camera orientation.
onPhotoCaptured(url);
});
};
img.onerror = function () { toast('照片读取失败,请重新选择'); };
@@ -1028,13 +956,9 @@
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
function onPhotoCaptured(dataUrl, mirrored) {
function onPhotoCaptured(dataUrl) {
shutterLock = true; // 推进窗口上锁(停顿期间不检测、不接受快门),直到下一步/送检
photos[camStep] = dataUrl;
if (camStep === 0) {
scanPhotoMirrored = !!mirrored;
setScanPhoto(dataUrl, scanPhotoMirrored); // Decode the front photo before the waiting screen opens.
}
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
var seq = camSeq, hasNext = camStep < CAM_STEPS.length - 1;
if (hasNext) {
@@ -1072,7 +996,7 @@
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
setTimeout(function () {
var shot = captureFromVideo(), mirrored = effMirror();
var shot = captureFromVideo();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
capturing = false;
@@ -1085,7 +1009,7 @@
shutterLock = false; holdState = null; okSince = 0;
setHint(error, false); armNudge(); return;
}
onPhotoCaptured(shot, mirrored);
onPhotoCaptured(shot);
});
}, 300);
}, 300);
@@ -1095,7 +1019,6 @@
function submitPhotos(seq) {
camStep = 0;
photos = photosByView(photos);
setScanPhoto(photos[0], scanPhotoMirrored);
openOverlay('p-analyzing');
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
// 档案与检测一起上报(后台记录用):年龄非法值服务端按 null 处理
@@ -1567,38 +1490,34 @@
});
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
document.querySelectorAll('.card-detail').forEach(function (card) {
document.querySelectorAll('.card-detail').forEach(function (card, group) {
card.querySelector('.detail-tabs').setAttribute('role', 'tablist');
card.querySelectorAll('.dtab').forEach(function (tab) { tab.setAttribute('role', 'tab'); });
setDetailTab(card, '0');
var gesture = null, suppressImageClick = false;
card.addEventListener('pointerdown', function (e) {
if (e.isPrimary === false || (e.pointerType === 'mouse' && e.button !== 0)) return;
gesture = { id: e.pointerId, x: e.clientX, y: e.clientY, horizontal: false };
suppressImageClick = false;
var tabs = Array.from(card.querySelectorAll('.dtab')), panels = Array.from(card.querySelectorAll('.dpanel'));
var viewport = card.querySelector('.detail-viewport'), track = card.querySelector('.detail-panels'), position = 0;
tabs.forEach(function (tab, i) {
tab.setAttribute('role', 'tab'); tab.id = 'detail-tab-' + group + '-' + i;
tab.setAttribute('aria-controls', 'detail-panel-' + group + '-' + i);
panels[i].id = 'detail-panel-' + group + '-' + i;
panels[i].setAttribute('role', 'tabpanel'); panels[i].setAttribute('aria-labelledby', tab.id);
});
card.addEventListener('pointermove', function (e) {
if (!gesture || gesture.id !== e.pointerId) return;
var dx = e.clientX - gesture.x, dy = e.clientY - gesture.y;
if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy) * 1.4) {
gesture.horizontal = true;
if (e.cancelable) e.preventDefault();
}
});
card.addEventListener('pointerup', function (e) {
if (!gesture || gesture.id !== e.pointerId) return;
var dx = e.clientX - gesture.x, dy = e.clientY - gesture.y;
if (gesture.horizontal && Math.abs(dx) >= 45 && Math.abs(dx) > Math.abs(dy) * 1.4) {
var tabs = Array.from(card.querySelectorAll('.dtab'));
var active = tabs.findIndex(function (tab) { return tab.classList.contains('active'); });
var next = Math.max(0, Math.min(tabs.length - 1, active + (dx < 0 ? 1 : -1)));
setDetailTab(card, tabs[next].dataset.tab);
suppressImageClick = true;
}
gesture = null;
});
card.addEventListener('pointercancel', function () { gesture = null; });
card.addEventListener('dragstart', function (e) { if (e.target.closest('.ditem-img')) e.preventDefault(); });
function height(at) {
position = at;
var lower = Math.floor(at), upper = Math.min(panels.length - 1, Math.ceil(at)), ratio = at - lower;
viewport.style.height = (panels[lower].offsetHeight * (1 - ratio) + panels[upper].offsetHeight * ratio) + 'px';
}
var swipe = SwipeTrack.create(viewport, track, { count: panels.length, gestureTarget: card,
onMove: height, onCancel: height, onSelect: function (i) { setDetailTab(card, String(i)); } });
detailSwipes.set(card, { swipe: swipe, height: height });
setDetailTab(card, '0', true);
if (typeof ResizeObserver !== 'undefined') {
var observer = new ResizeObserver(function () { height(position); });
panels.forEach(function (panel) { observer.observe(panel); });
} else {
window.addEventListener('resize', function () { height(position); });
card.addEventListener('load', function () { height(position); }, true);
if (document.fonts) document.fonts.ready.then(function () { height(position); });
}
card.querySelectorAll('.ditem-img').forEach(function (image) { image.draggable = false; });
card.addEventListener('keydown', function (e) {
var tab = e.target.closest('.dtab');
if (!tab || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
@@ -1608,11 +1527,10 @@
setDetailTab(card, tabs[index].dataset.tab); tabs[index].focus({ preventScroll: true });
});
card.addEventListener('click', function (e) {
if (suppressImageClick) { suppressImageClick = false; e.preventDefault(); return; }
var btn = e.target.closest('.dtab');
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
var img = e.target.closest('.ditem-img');
if (img && img.src && !img.hidden) {
if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) {
$('#zoom-img').src = img.src;
openOverlay('p-zoom');
history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
@@ -1653,7 +1571,6 @@
camSeq++; // 使旧的快门/分析回调失效
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
scanPhotoMirrored = false; clearScanPhoto();
captureDiagnostics = [];
shutterLock = false; // 重新进入流程,保险解锁
showScreen('s-cam', 'push');
+3 -3
View File
@@ -10,9 +10,9 @@
['sens', '泛红', 'stab', 'sens'], ['acne', '痘痘', 'stab', 'acne']
];
var products = [
{ tier: 1, match: '25+束紧精华', title: "1'me 主场精华 I", name: '以我本然有光蓄活', image: 'assets/img/ui-20261012/product-25.webp' },
{ tier: 2, match: '35+支撑精华', title: "1'me 主场精华 II", name: '以我自在有势挺括', image: 'assets/img/ui-20261012/product-35.webp' },
{ tier: 3, match: '50+重塑精华', title: "1'me 主场精华 III", name: '以我从容有态构塑', image: 'assets/img/ui-20261012/product-50.webp' }
{ tier: 1, match: '25+束紧精华', title: "1'me 主场精华 I", name: '以我本然有光蓄活', image: 'assets/img/ui-20261012/product-25.jpg' },
{ tier: 2, match: '35+支撑精华', title: "1'me 主场精华 II", name: '以我自在有势挺括', image: 'assets/img/ui-20261012/product-35.jpg' },
{ tier: 3, match: '50+重塑精华', title: "1'me 主场精华 III", name: '以我从容有态构塑', image: 'assets/img/ui-20261012/product-50.jpg' }
];
var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' };
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
+80
View File
@@ -0,0 +1,80 @@
/* Shared finger-following track for skin choices and report detail panels. */
window.SwipeTrack = (function () {
'use strict';
function create(viewport, track, options) {
var target = options.gestureTarget || viewport, index = 0;
var drag = { id: null, horizontal: false, vertical: false }, suppressUntil = 0;
function release() {
var id = drag.id;
drag.id = null; drag.horizontal = drag.vertical = false;
viewport.classList.remove('dragging'); track.classList.remove('dragging');
try { if (id != null && target.hasPointerCapture(id)) target.releasePointerCapture(id); } catch (_) {}
}
function go(next, instant) {
index = Math.max(0, Math.min(options.count - 1, next));
release();
if (instant) track.style.transition = 'none';
track.style.transform = 'translateX(-' + index * 100 + '%)';
if (instant) { void track.offsetWidth; track.style.transition = ''; }
}
function cancel() { go(index, true); if (options.onCancel) options.onCancel(index); }
target.addEventListener('pointerdown', function (e) {
if (drag.id != null || e.isPrimary === false || e.button !== 0 || !viewport.clientWidth) return;
suppressUntil = 0; // A fresh tap is intentional, not the preceding drag's click.
drag.id = e.pointerId; drag.startX = e.clientX; drag.startY = e.clientY;
drag.offset = track.getBoundingClientRect().left - viewport.getBoundingClientRect().left;
drag.width = viewport.clientWidth; drag.lastX = e.clientX; drag.lastAt = e.timeStamp; drag.velocity = 0;
drag.horizontal = drag.vertical = false;
// Capture only after direction lock: an ordinary image tap must still zoom.
});
target.addEventListener('pointermove', function (e) {
if (e.pointerId !== drag.id || drag.vertical) return;
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
if (!drag.horizontal) {
if (Math.abs(dy) > 8 && Math.abs(dy) >= Math.abs(dx)) { drag.vertical = true; return; }
if (Math.abs(dx) <= 8 || Math.abs(dx) <= Math.abs(dy)) return;
drag.horizontal = true; viewport.classList.add('dragging'); track.classList.add('dragging');
try { target.setPointerCapture(e.pointerId); } catch (_) {}
}
if (e.cancelable) e.preventDefault();
var elapsed = e.timeStamp - drag.lastAt;
if (elapsed > 0) drag.velocity = (e.clientX - drag.lastX) / elapsed;
drag.lastX = e.clientX; drag.lastAt = e.timeStamp;
var offset = drag.offset + dx, min = -(options.count - 1) * drag.width;
if (offset > 0) offset *= .25;
else if (offset < min) offset = min + (offset - min) * .25;
track.style.transform = 'translate3d(' + offset + 'px,0,0)';
if (options.onMove) options.onMove(Math.max(0, Math.min(options.count - 1, -offset / drag.width)));
});
window.addEventListener('pointerup', function (e) {
if (e.pointerId !== drag.id) return;
var horizontal = drag.horizontal, dx = e.clientX - drag.startX, next = index;
if (horizontal) {
var start = Math.round(-drag.offset / drag.width);
next = start;
var speed = e.timeStamp - drag.lastAt > 100 ? 0 : drag.velocity;
if (Math.abs(dx) >= Math.min(50, drag.width * .2) || (Math.abs(dx) > 12 && Math.abs(speed) > .4)) {
next = Math.round(-(drag.offset + dx) / drag.width);
if (next === start) next += (Math.abs(speed) > .4 ? speed : dx) < 0 ? 1 : -1;
}
suppressUntil = Date.now() + 350;
}
go(next);
if (horizontal && options.onSelect) options.onSelect(index);
});
window.addEventListener('pointercancel', function (e) { if (e.pointerId === drag.id) cancel(); });
target.addEventListener('lostpointercapture', function (e) {
// Touch starts with implicit capture on an image/button. Its bubbled loss
// when we take horizontal capture is not a cancellation of this track.
if (e.target === target && e.pointerId === drag.id) cancel();
});
target.addEventListener('dragstart', function (e) { e.preventDefault(); });
target.addEventListener('click', function (e) {
if (Date.now() < suppressUntil) { e.preventDefault(); e.stopPropagation(); }
}, true);
window.addEventListener('resize', cancel);
go(0, true);
return { go: go, cancel: cancel, isDragging: function () { return drag.horizontal; } };
}
return { create: create };
})();
+3 -3
View File
@@ -97,10 +97,10 @@ try {
const sent = page.waitForRequest(r => r.url().endsWith('/api/skin-analyze'));
await page.evaluate(() => window.__cameraFixture.set('C3_-135.jpg'));
const body = (await sent).postDataJSON();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), body.front_image, 'real live waiting uses the captured front photo');
assert.equal(await page.locator('#scan-photo.mirror').count(), 1, 'front camera waiting keeps the preview mirror without changing the sent JPEG');
assert.equal(await page.locator('#scan-photo').count(), 0, 'live waiting does not display the captured face');
assert.equal(await page.locator('#scan-artwork').count(), 1, 'waiting retains original PSD head');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'waiting photo reference is cleared after success');
assert.equal(await page.locator('#p-analyzing.show').count(), 0, 'waiting is cleared after success');
for (const key of ['front', 'left', 'right']) {
const row = body.capture_diagnostics.views[key];
assert.equal(row.mode, 'live'); assert.equal(row.direction_checked, true);
+3 -4
View File
@@ -61,7 +61,7 @@ try {
await page.goto(base);
await page.waitForSelector('#s-splash.active');
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /\.woff2$|faceapi|scan-(waiting|base|face)|product-\d+\.webp/.test(p)), 'heavy/scanning assets must not compete with entry');
assert(!requests.some(p => /\.woff2$|faceapi|scan-(waiting|base|face)|product-\d+\.(webp|jpg)/.test(p)), 'heavy/scanning assets must not compete with entry');
await page.waitForFunction(() => document.querySelector('#s-splash .splash-slogan').naturalWidth === 718 && document.querySelector('.brand-lockup').naturalWidth === 517);
await screenshot('01-home');
await page.click('[data-action="open-privacy"]');
@@ -122,8 +122,7 @@ try {
});
assert(scanPixels[0].slice(0, 3).every(value => value > 245), 'original PSD bitmap is unchanged');
assert(scanPixels[1].slice(0, 3).every(value => value > 245), 'original PSD bitmap is unchanged');
assert.equal(await page.locator('#scan-photo').getAttribute('src'), pending.request().postDataJSON().front_image, 'real front photograph covers the white PSD placeholder');
assert(await page.locator('#scan-photo').evaluate(image => image.complete && image.naturalWidth > 0));
assert.equal(await page.locator('#scan-photo').count(), 0, 'white PSD head is not covered by user photograph');
assert.equal(await page.locator('.scan-beam').evaluate(e => getComputedStyle(e).animationName), 'report-scan');
assert.equal(await page.locator('#p-analyzing [data-action]').count(), 1, 'only cancel is actionable; PSD icons decorative');
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
@@ -173,7 +172,7 @@ try {
});
await page.goto(base + '?mock=1&screen=report'); await loadFonts();
await page.waitForFunction(() => document.querySelector('#product-image').naturalWidth === 504);
assert((await page.locator('#product-image').getAttribute('src')).endsWith('product-' + ({ 1: 25, 2: 35, 3: 50 }[tier]) + '.webp'));
assert((await page.locator('#product-image').getAttribute('src')).endsWith('product-' + ({ 1: 25, 2: 35, 3: 50 }[tier]) + '.jpg'));
for (const width of widths) {
await page.setViewportSize({ width, height: 844 });
await page.locator('.card-product').scrollIntoViewIfNeeded();
+130
View File
@@ -0,0 +1,130 @@
// Real mobile pointer dispatch on the actual report. Auth/analysis writes blocked.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
const root = fileURLToPath(new URL('../../', import.meta.url));
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'design/ui-20261012/delivery-optimization-20261012.json')));
for (const asset of manifest.assets) {
for (const [file, expected] of [[asset.source, asset.source_sha256], [asset.file, asset.sha256]]) {
assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex'), expected);
}
assert(asset.bytes < asset.source_bytes * .9 && asset.psnr_db >= 40);
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
const errors = [], writes = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => {
const req = route.request();
if (req.method() !== 'GET') { writes.push(req.url()); return route.abort(); }
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'report-touch-fixture', kind: 'browser' } } });
return route.continue();
});
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--' && [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
await page.evaluate(async () => { await Promise.all([document.fonts.load('500 40px MiSans'), document.fonts.load('400 12px MiSans')]); });
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 844 });
await page.waitForTimeout(400);
for (const score of ['0', '75', '100', '--']) {
await page.locator('#stat-score b').evaluate((el, value) => { el.textContent = value; }, score);
const layout = await page.evaluate(() => {
const rect = selector => {
const r = document.querySelector(selector).getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom };
};
return { dial: rect('.score-dial'), score: rect('#stat-score b'), title: rect('.sum-score > .stat-label'),
ageTitle: rect('.sum-age > .stat-label'), age: rect('.age-dial'),
careAlign: getComputedStyle(document.querySelector('.card-care .card-heading')).textAlign };
});
assert(Math.abs(layout.score.x + layout.score.width / 2 - layout.dial.x - layout.dial.width / 2) < 1, 'score horizontally centered: ' + width + '/' + score);
assert(Math.abs(layout.score.y + layout.score.height / 2 - layout.dial.y - layout.dial.height / 2) < 1, 'score vertically centered');
assert(layout.title.bottom < layout.dial.y && layout.ageTitle.bottom < layout.age.y, 'labels outside above their values');
assert(Math.abs(layout.title.y - layout.ageTitle.y) < 1, 'labels share top alignment');
assert.equal(layout.careAlign, 'left');
}
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
await page.locator('#stat-score b').evaluate(el => { el.textContent = '75'; });
await page.setViewportSize({ width: 375, height: 726 });
const cdp = await page.context().newCDPSession(page), cards = page.locator('.card-detail');
const offset = card => card.locator('.detail-panels').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m41);
const selected = card => card.locator('.dtab.active').getAttribute('data-tab');
const touch = (type, x, y, timestamp) => cdp.send('Input.dispatchTouchEvent', {
type, ...(timestamp ? { timestamp } : {}), touchPoints: /touchEnd|touchCancel/.test(type) ? [] : [{ x, y, id: 1 }]
});
async function drag(card, dx, { dy = 0, cancel = false, pause = 0, flick = false, verify = false } = {}) {
const frame = card.locator('.dpanel.active .viz-frame');
await frame.scrollIntoViewIfNeeded(); await page.waitForTimeout(400);
const box = await frame.boundingBox(), x = box.x + box.width / 2, y = Math.min(500, box.y + box.height / 2);
const before = await offset(card), previous = await selected(card), now = flick ? Date.now() / 1000 : null;
await touch('touchStart', x, y, now);
await touch('touchMove', x + dx / 2, y + dy / 2, now && now + .02);
await touch('touchMove', x + dx, y + dy, now && now + .04);
if (verify) {
assert(Math.abs(await offset(card) - before - dx) < 3, 'report follows finger before release');
assert.equal(await selected(card), previous, 'selection commits only on release');
assert.equal(await card.locator('.detail-panels.dragging').count(), 1);
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'report-drag-' + await card.locator('.dtab').first().textContent() + '.png') });
}
}
if (pause) await page.waitForTimeout(pause);
await touch(cancel ? 'touchCancel' : 'touchEnd', 0, 0, now && now + .05);
await page.waitForTimeout(400);
assert.equal(await card.locator('.detail-panels.dragging').count(), 0);
assert.equal(await page.locator('#p-zoom.show').count(), 0, 'drag never opens zoom');
}
async function aligned(card) {
const state = await card.evaluate(el => {
const viewport = el.querySelector('.detail-viewport'), active = el.querySelector('.dpanel.active');
return { viewport: viewport.getBoundingClientRect().height, panel: active.offsetHeight,
delta: active.getBoundingClientRect().left - viewport.getBoundingClientRect().left,
selected: el.querySelectorAll('.dtab[aria-selected="true"][tabindex="0"]').length,
accessible: [...el.querySelectorAll('.dpanel')].filter(p => !p.inert && p.getAttribute('aria-hidden') === 'false').length };
});
assert(Math.abs(state.viewport - state.panel) < 2 && Math.abs(state.delta) < 2, 'selected panel fits/aligned: ' + JSON.stringify(state));
assert.equal(state.selected, 1); assert.equal(state.accessible, 1);
}
for (let i = 0; i < 3; i++) {
const card = cards.nth(i);
await drag(card, -100, { verify: true }); assert.equal(await selected(card), '1'); await aligned(card);
await drag(card, 25, { pause: 160 }); assert.equal(await selected(card), '1', 'short slow drag snaps back');
await drag(card, 100, { cancel: true }); assert.equal(await selected(card), '1', 'cancellation restores selection');
await drag(card, 2, { dy: -90 }); assert.equal(await selected(card), '1', 'vertical scrolling preserves tab');
await drag(card, 40, { flick: true }); assert.equal(await selected(card), '0', 'short fast flick goes back');
await drag(card, 100); assert.equal(await selected(card), '0', 'first edge cannot overshoot');
await card.locator('.dtab').last().click(); await page.waitForTimeout(400); await aligned(card);
await drag(card, -100); assert.equal(await selected(card), String(i === 1 ? 2 : 1), 'last edge cannot overshoot');
await card.locator('.dtab').last().focus(); await page.keyboard.press('Home'); await page.waitForTimeout(400); await aligned(card);
}
await drag(cards.nth(1), -100); await drag(cards.nth(1), -100); assert.equal(await selected(cards.nth(1)), '2');
assert.equal(await selected(cards.nth(0)), '0'); assert.equal(await selected(cards.nth(2)), '0', 'groups are independent');
const image = cards.nth(0).locator('.dpanel.active .ditem-img');
await image.click(); await page.waitForSelector('#p-zoom.show');
assert.equal(await page.locator('#zoom-img').getAttribute('src'), await image.getAttribute('src'));
await page.click('#p-zoom'); await page.waitForTimeout(400);
for (const width of [320, 390, 480]) {
await page.setViewportSize({ width, height: 844 }); await page.waitForTimeout(400);
for (const card of await cards.all()) await aligned(card);
}
for (const asset of manifest.assets) {
const response = await page.request.get(new URL(asset.file.replace(/^h5\//, ''), base).href);
assert.equal(response.status(), 200); assert(response.headers()['content-type'].startsWith('image/jpeg'));
assert.equal((await response.body()).length, asset.bytes);
}
if (process.env.H5_ARTIFACT_DIR) {
await page.setViewportSize({ width: 375, height: 726 });
await page.locator('#s-report').evaluate(el => { el.scrollTop = 0; });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'report-summary.png') });
}
assert.deepEqual(errors, []); assert.deepEqual(writes, []);
console.log('PASS: score centered/labels above/care left in five widths; real finger-following three report groups, flick/snap/cancel/vertical/edges/keyboard/zoom/resize/height/ARIA; smaller JPEG hashes/dimensions/MIME; no JS errors/backend writes');
} finally { await browser.close(); }
@@ -16,7 +16,7 @@ try {
let pending, artworkFailure = false;
await page.route('**/*', route => {
const req = route.request();
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'scan-photo-fixture', kind: 'browser' } } });
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'scan-placeholder-fixture', kind: 'browser' } } });
if (req.url().endsWith('/api/skin-analyze')) { pending = route; return; }
if (req.method() !== 'GET') { unexpectedWrites.push(req.url()); return route.abort(); }
if (/\.woff2$|\/vendor\/faceapi\//.test(req.url())) return route.abort();
@@ -48,39 +48,43 @@ try {
await page.setInputFiles('#cam-file', path.join(samples, name));
}
await page.waitForSelector('#p-analyzing.show');
await page.waitForFunction(() => { const img = document.querySelector('#scan-photo'); return img.complete && img.naturalWidth > 0; });
assert(pending);
const sent = pending.request().postDataJSON();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), sent.front_image, 'waiting face is the current FRONT photo, not the last side or a placeholder');
assert.equal(await page.locator('#scan-photo').count(), 0, 'waiting never inserts a user face');
assert.equal(await page.locator('#p-analyzing img[src^="data:"]').count(), 0);
assert.notEqual(sent.front_image, sent.left_image); assert.notEqual(sent.front_image, sent.right_image);
assert.equal(await page.locator('#scan-photo.mirror').count(), 0, 'uploaded/system camera photos must not be mirrored again');
assert.equal(await page.locator('#scan-photo').evaluate(img => getComputedStyle(img).objectFit), 'cover');
assert.equal(await page.locator('#scan-face').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(17, 17, 17)', 'loading/error does not expose white placeholder');
return sent;
}
function rejectPending(message) { const route = pending; pending = null; return route.fulfill({ json: { ok: false, message } }); }
const cleared = async () => {
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'face reference cleared on exit');
assert.equal(await page.locator('#scan-photo').count(), 0);
assert.equal(await page.locator('#p-analyzing.scan-running').count(), 0);
};
await profile();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'no face before capture');
assert.equal(await page.locator('#scan-photo').count(), 0, 'no user photo element');
await capture();
await page.waitForSelector('#p-analyzing.scan-artwork-ready');
assert((await page.locator('#scan-face').evaluate(el => getComputedStyle(el).maskImage)).includes('scan-face-mask.png'));
assert.equal(await page.locator('.scan-beam').evaluate(el => getComputedStyle(el).animationName), 'report-scan');
const pixels = await page.locator('#scan-artwork').evaluate(image => {
const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight;
const context = canvas.getContext('2d'); context.drawImage(image, 0, 0);
return [[540, 720], [540, 1200]].map(([x, y]) => [...context.getImageData(x, y, 1, 1).data]);
});
assert(pixels.every(pixel => pixel.slice(0, 3).every(value => value > 245)), 'original white head is preserved');
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
await page.setViewportSize({ width, height });
const photo = await page.locator('#scan-photo').boundingBox();
assert(Math.abs(photo.width - Math.min(width, 480)) < 1 && Math.abs(photo.height - height) < 1, 'same cover viewport as camera');
assert(Math.abs(photo.x - (width - photo.width) / 2) < 1 && Math.abs(photo.y) < 1, 'photo crop stays centered independently of PSD contain mask');
const art = await page.locator('#scan-artwork').boundingBox();
assert(art.width <= width + 1 && art.height <= height + 1);
assert(Math.abs(art.width / art.height - 1080 / 2091) < .001, 'PSD is contained without cropping');
assert(Math.abs(art.x - (width - art.width) / 2) < 1 && Math.abs(art.y - (height - art.height) / 2) < 1);
assert(await page.locator('.scan-cancel').isVisible());
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
await page.setViewportSize({ width: 375, height: 726 });
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'scan-photo.png') });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'scan-placeholder.png') });
}
await page.mouse.click(187, 350);
assert(await page.locator('#p-analyzing').isVisible(), 'clicking the face does not cancel');
@@ -97,14 +101,13 @@ try {
await pending.fulfill({ json: success }); pending = null;
await page.waitForSelector('#s-report.active'); await cleared();
assert(await page.evaluate(() => window.__scanIterations.some(row => row.target === 'scan-beam' && row.elapsed >= 1)), 'fast success completes an actual cycle');
// Failed artwork/mask preserves the real photograph + status + cancel.
// Failed artwork/mask preserves a readable status and cancel, not a user photo.
artworkFailure = true;
await profile(); await capture();
assert.equal(await page.locator('#p-analyzing.scan-artwork-ready').count(), 0);
assert.equal(await page.locator('#scan-face').evaluate(el => getComputedStyle(el).maskImage), 'none');
assert.equal(await page.locator('.scan-status').evaluate(el => getComputedStyle(el).clipPath), 'none');
assert.equal(await page.locator('#scan-photo').evaluate(img => getComputedStyle(img).visibility), 'visible');
assert.equal(await page.locator('#scan-artwork').evaluate(img => getComputedStyle(img).visibility), 'hidden');
await page.click('[data-action="cancel-analysis"]'); await cleared(); await rejectPending('canceled');
assert.deepEqual(errors, []); assert.deepEqual(unexpectedWrites, []);
console.log('PASS: actual three-photo waiting shows current front JPEG; original orientation/cover/PSD mask/scan; six viewport sizes; photo cleanup after cancel/error/success; stale response; one real reduced-motion cycle; image/mask failure fallback; no JS errors/backend writes');
console.log('PASS: actual three-photo waiting restores original white PSD head, no user photograph; six viewport sizes; cancel/error/success cleanup; stale response; one real reduced-motion cycle; image/mask failure fallback; no JS errors/backend writes');
} finally { await browser.close(); }