HEL-825 review: link-card accessible-name selector fix (browser_check), evidence scripts, regenerated screenshots/recordings

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Chief Engineer
2026-10-24 08:27:06 +08:00
co-authored by multica-agent
parent 0bc4a9e68e
commit e32a1ac0d2
35 changed files with 314 additions and 10 deletions
+1 -1
View File
@@ -18,5 +18,5 @@
"No uncaught browser errors, API/external requests, microphone or camera requests"
],
"errors": [],
"requests": 56
"requests": 54
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+8 -8
View File
@@ -25,8 +25,8 @@
"reader": {
"top": 762,
"bottom": 792,
"left": 596.5,
"right": 843.5,
"left": 596.484375,
"right": 843.515625,
"height": 30
},
"composer": {
@@ -75,8 +75,8 @@
"reader": {
"top": 762,
"bottom": 792,
"left": 596.5,
"right": 843.5,
"left": 596.484375,
"right": 843.515625,
"height": 30
},
"composer": {
@@ -511,7 +511,7 @@
"reader": {
"top": 252,
"bottom": 282,
"left": 359.5,
"left": 359.484375,
"right": 484.5,
"height": 30
},
@@ -554,7 +554,7 @@
"reader": {
"top": 252,
"bottom": 282,
"left": 359.5,
"left": 359.484375,
"right": 484.5,
"height": 30
},
@@ -586,9 +586,9 @@
],
"motion": {
"inked_lines": {
"a7926846-51f6-4d1a-8057-565c2350c71d:1:0": 1
"6fc4f8ff-218a-4c0a-933c-7982fef596b6:1:0": 1
},
"slow_frames_new_turn_to_reply_s": 2.32
"slow_frames_new_turn_to_reply_s": 2.31
},
"console_errors": [],
"limits": [
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 136 KiB

+1 -1
View File
@@ -97,7 +97,7 @@ try:
click(page, '读到了书里的那片海', exact=False)
expect(page.get_by_role('heading', name='读到了书里的那片海')).to_be_visible()
shot(page, f'{width}-days-{theme}')
click(page, '留一盏小灯')
click(page, '留一盏小灯', exact=False)
expect(page.get_by_role('heading', name='留一盏小灯')).to_be_visible()
click(page, '读到了书里的那片海', exact=False)
click(page, '在交谈里看这段')
+235
View File
@@ -0,0 +1,235 @@
"""HEL-825 review evidence: named viewports x both themes, confirm dialogs,
error states, floating tag, plus two short screen recordings."""
import functools
import http.server
import threading
from pathlib import Path
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parents[0] / 'docs' / 'frontend' / 'screenshots' / 'hel825'
OUT.mkdir(parents=True, exist_ok=True)
VID = ROOT.parents[0] / 'docs' / 'frontend' / 'recordings'
VID.mkdir(parents=True, exist_ok=True)
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, *_):
pass
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), functools.partial(Quiet, directory=str(ROOT / 'dist')))
threading.Thread(target=server.serve_forever, daemon=True).start()
url = f'http://127.0.0.1:{server.server_port}'
passed = []
errors = []
def log_pass(text):
passed.append(text)
print('PASS', text, flush=True)
with sync_playwright() as pw:
browser = pw.chromium.launch()
def fresh(width, height, record=None):
ctx = browser.new_context(viewport={'width': width, 'height': height}, record_video_dir=(str(VID) if record else None),
record_video_size={'width': min(width, 1280), 'height': min(height, 800)} if record else None)
page = ctx.new_page()
page.set_default_timeout(9000)
page.on('pageerror', lambda e: errors.append(str(e)))
return ctx, page
def setup_demo(page, width):
page.goto(url + '/?demo=1')
page.get_by_role('button', name='下一步').click()
page.get_by_label('她的名字', exact=True).fill('小满')
page.get_by_label('出生日期(公历)', exact=True).fill('2000-10-18')
page.get_by_role('button', name='下一步').click()
page.get_by_role('button', name='下一步').click()
page.get_by_role('button', name='朋友', exact=True).click()
page.get_by_role('button', name='下一步').click()
page.get_by_role('checkbox').check()
page.get_by_role('button', name='确认创建', exact=True).click()
page.get_by_role('button', name='去看看她').click()
page.locator('.demo > button').click()
page.get_by_role('button', name='载入三天记录').click()
page.locator('.demo > button').click()
def nav(page, name):
page.get_by_role('navigation', name='主导航').get_by_role('button', name=name, exact=True).click()
page.wait_for_timeout(450)
if name == '设置' and page.viewport_size['width'] <= 720 and page.locator('.pane.open.has-detail').count():
page.locator('.pane.open .back').click()
def shot(page, name):
page.screenshot(path=str(OUT / f'{name}.png'), full_page=False)
# --- 1600x1000 and 1024 wide: full sweep both themes ---
for width, height in [(1600, 1000), (1024, 1000)]:
ctx, page = fresh(width, height)
setup_demo(page, width)
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
page.locator('.demo > button').click()
page.get_by_role('button', name=label, exact=True).click()
page.wait_for_timeout(400)
shot(page, f'{width}x{height}-home-{theme}')
# her-now floating tag under role on wide, above panel title <=1100
nav(page, '日子')
if page.get_by_role('tab', name='她的日子').get_attribute('aria-selected') != 'true':
page.get_by_role('tab', name='她的日子').click()
page.wait_for_timeout(300)
shot(page, f'{width}x{height}-days-{theme}')
page.get_by_role('button', name='读到了书里的那片海').click()
expect(page.get_by_role('heading', name='读到了书里的那片海')).to_be_visible()
shot(page, f'{width}x{height}-days-detail-{theme}')
page.get_by_role('tab', name='我们的回忆').click()
page.wait_for_timeout(300)
shot(page, f'{width}x{height}-memory-{theme}')
page.get_by_role('button', name='留一盏小灯', exact=False).first.click()
expect(page.get_by_role('heading', name='留一盏小灯')).to_be_visible()
shot(page, f'{width}x{height}-memory-detail-{theme}')
# settings + relation confirm dialog
nav(page, '设置')
page.get_by_role('navigation', name='设置分区').get_by_role('button', name='关系', exact=True).click()
page.get_by_label('她怎么称呼你', exact=True).fill('小伙伴')
page.get_by_role('group', name='你们的关系').get_by_role('button', name='恋人', exact=True).click()
page.get_by_role('button', name='保存').click()
expect(page.get_by_role('dialog')).to_contain_text('记忆与信任都保留')
shot(page, f'{width}x{height}-relation-confirm-{theme}')
page.get_by_role('dialog').get_by_role('button', name='确认', exact=True).click()
expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
# reset relation back to friend so the next theme loop has a changeable draft
page.get_by_role('group', name='你们的关系').get_by_role('button', name='朋友', exact=True).click()
page.get_by_role('button', name='保存').click()
page.get_by_role('dialog').get_by_role('button', name='确认', exact=True).click()
expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
page.wait_for_timeout(400)
shot(page, f'{width}x{height}-settings-{theme}')
# read-fail retry card via demo failure switch
nav(page, '日子')
if page.get_by_role('tab', name='她的日子').get_attribute('aria-selected') != 'true':
page.get_by_role('tab', name='她的日子').click()
page.wait_for_timeout(300)
page.locator('.demo > button').click()
page.get_by_role('button', name='下次操作模拟失败').click()
page.locator('.demo > button').click()
page.reload()
nav(page, '日子')
page.wait_for_timeout(600)
if page.get_by_role('button', name='再试一次').count():
shot(page, f'{width}x{height}-read-fail-{theme}')
log_pass(f'{width}x{height} {theme}: read-failure card with retry captured')
else:
page.locator('.demo > button').click()
page.get_by_role('button', name='载入三天记录').click()
page.locator('.demo > button').click()
# restore data for next theme loop
page.locator('.demo > button').click()
page.get_by_role('button', name='载入三天记录').click()
page.locator('.demo > button').click()
ctx.close()
log_pass(f'{width}x{height}: home/days/detail/memory/settings/confirm both themes')
# --- phone viewports ---
for width, height in [(390, 844), (320, 568)]:
ctx, page = fresh(width, height)
setup_demo(page, width)
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
page.locator('.demo > button').click()
page.get_by_role('button', name=label, exact=True).click()
page.wait_for_timeout(400)
shot(page, f'{width}x{height}-home-{theme}')
nav(page, '日子')
if page.locator('.pane.open.has-detail').count():
page.locator('.pane.open .back').click()
shot(page, f'{width}x{height}-days-{theme}')
page.get_by_role('button', name='读到了书里的那片海').click()
expect(page.get_by_role('heading', name='读到了书里的那片海')).to_be_visible()
shot(page, f'{width}x{height}-days-detail-{theme}')
nav(page, '设置')
page.get_by_role('navigation', name='设置分区').get_by_role('button', name='她', exact=True).click()
page.wait_for_timeout(300)
shot(page, f'{width}x{height}-settings-her-{theme}')
ctx.close()
log_pass(f'{width}x{height}: home/days/detail/settings both themes')
# --- 390x430 keyboard-equivalent: composer focused geometry ---
ctx, page = fresh(390, 430)
setup_demo(page, 390)
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
page.locator('.demo > button').click()
page.get_by_role('button', name=label, exact=True).click()
page.wait_for_timeout(400)
nav(page, '交谈')
composer = page.get_by_role('textbox', name='想和她说的话')
composer.click()
page.wait_for_timeout(500)
shot(page, f'390x430-keyboard-{theme}')
expect(composer).to_be_in_viewport()
ctx.close()
log_pass('390x430 keyboard-equivalent: composer focused, in viewport, both themes')
# --- recordings: wide nav/dialog/esc, phone nav/back ---
ctx, page = fresh(1600, 1000, record=True)
setup_demo(page, 1600)
page.wait_for_timeout(600)
nav(page, '日子')
page.get_by_role('button', name='读到了书里的那片海').click()
page.wait_for_timeout(700)
page.get_by_role('button', name='在交谈里看这段').click()
page.wait_for_timeout(900)
page.keyboard.press('Escape')
page.wait_for_timeout(600)
page.get_by_role('button', name='结束交谈 · 回到日子').click()
page.wait_for_timeout(600)
nav(page, '设置')
page.get_by_role('navigation', name='设置分区').get_by_role('button', name='关系', exact=True).click()
page.get_by_label('她怎么称呼你', exact=True).fill('小伙伴')
page.get_by_role('group', name='你们的关系').get_by_role('button', name='恋人', exact=True).click()
page.get_by_role('button', name='保存').click()
page.wait_for_timeout(500)
page.get_by_role('dialog').get_by_role('button', name='确认', exact=True).click()
page.wait_for_timeout(700)
nav(page, '此刻')
page.wait_for_timeout(800)
video = page.video.path()
ctx.close()
Path(VID / 'hel825-wide-nav-dialog-esc.webm')
if video:
import shutil
shutil.move(video, VID / 'hel825-wide-nav-dialog-esc.webm')
log_pass('wide recording: days->chat jump, Esc, back, relation confirm, nav')
ctx, page = fresh(390, 844, record=True)
setup_demo(page, 390)
page.wait_for_timeout(600)
nav(page, '日子')
page.get_by_role('button', name='读到了书里的那片海').click()
page.wait_for_timeout(700)
page.keyboard.press('Escape')
page.wait_for_timeout(600)
nav(page, '交谈')
composer = page.get_by_role('textbox', name='想和她说的话')
composer.click()
page.wait_for_timeout(800)
composer.fill('你好呀')
page.get_by_role('button', name='发送', exact=True).click()
page.wait_for_timeout(1200)
nav(page, '此刻')
page.wait_for_timeout(700)
video = page.video.path()
ctx.close()
if video:
import shutil
shutil.move(video, VID / 'hel825-phone-nav-esc.webm')
log_pass('phone recording: days detail, Esc back, talk send, nav')
browser.close()
server.shutdown()
print('ERRORS:', errors if errors else 'none')
if errors:
raise SystemExit(1)
+69
View File
@@ -0,0 +1,69 @@
"""HEL-825: geometry probe for the 她此刻 floating tag (no image reading needed).
Rule: with the days panel open, >1100px wide the tag sits under the role stage;
<=1100px it sits above the panel title."""
import functools
import http.server
import threading
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, *_):
pass
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), functools.partial(Quiet, directory=str(ROOT / 'dist')))
threading.Thread(target=server.serve_forever, daemon=True).start()
url = f'http://127.0.0.1:{server.server_port}'
with sync_playwright() as pw:
browser = pw.chromium.launch()
for width, expect_mode in [(1600, 'under-role'), (1440, 'under-role'), (1101, 'under-role'), (1100, 'above-title'), (1024, 'above-title'), (390, 'above-title')]:
ctx = browser.new_context(viewport={'width': width, 'height': 1000 if width > 800 else 844})
page = ctx.new_page()
page.set_default_timeout(9000)
page.goto(url + '/?demo=1')
page.get_by_role('button', name='下一步').click()
page.get_by_label('她的名字', exact=True).fill('小满')
page.get_by_label('出生日期(公历)', exact=True).fill('2000-10-18')
page.get_by_role('button', name='下一步').click()
page.get_by_role('button', name='下一步').click()
page.get_by_role('button', name='朋友', exact=True).click()
page.get_by_role('button', name='下一步').click()
page.get_by_role('checkbox').check()
page.get_by_role('button', name='确认创建', exact=True).click()
page.get_by_role('button', name='去看看她').click()
page.locator('.demo > button').click()
page.get_by_role('button', name='载入三天记录').click()
page.locator('.demo > button').click()
page.get_by_role('navigation', name='主导航').get_by_role('button', name='日子', exact=True).click()
page.wait_for_timeout(900)
info = page.evaluate("""() => {
const chip = document.querySelector('.now-chip');
const pane = document.querySelector('.pane.read');
const stage = document.querySelector('.stage') || document.querySelector('.who');
const title = pane ? pane.querySelector('h2') : null;
const near = pane ? pane.querySelector('.k-near') : null;
const r = el => { if (!el) return null; const b = el.getBoundingClientRect(); return {top: b.top, bottom: b.bottom, left: b.left, right: b.right, text: (el.textContent||'').trim().slice(0,40), display: el ? getComputedStyle(el).display : null}; };
return {chip: r(chip), pane: r(pane), stage: r(stage), title: r(title), near: r(near)};
}""")
chip, stage, title, near = info['chip'], info['stage'], info['title'], info['near']
ok = False
detail = ''
if expect_mode == 'under-role' and chip and stage:
ok = chip['display'] != 'none' and chip['top'] >= stage['bottom'] - 6
detail = f"chip_top={chip['top']:.0f} chip_display={chip['display']} stage_bottom={stage['bottom']:.0f}"
elif expect_mode == 'above-title' and title and near:
ok = near['display'] != 'none' and near['bottom'] <= title['top'] + 6 and near['text'].startswith('她此刻')
detail = f"near_bottom={near['bottom']:.0f} title_top={title['top']:.0f} near={near['text'][:24]}"
print(('PASS' if ok else 'FAIL'), width, expect_mode, detail, flush=True)
if not ok:
raise SystemExit(1)
ctx.close()
browser.close()
server.shutdown()
print('ALL GEOMETRY OK')