feat: full block visual bridge — platform blocks get trade-pro template-grade styling

Task 13a. Rewrite themes/trade-pro/blocks.css as two layers:
- Layer A: current app/static/blocks.css verbatim (old theme copy was stale,
  missing V8.34+ hero slider / layout-variant / card-style rules); keeps all
  platform contracts (site-runtime.js hero track, responsive.py 820/540 device
  semantics, card-style/fit variants, V8.33.1 section-vs-inner class fix).
- Layer B: .sf-theme-trade-pro scoped bridge to the sf-tp-* template language:
  hero (dark gradient + scrim + inverse button), features (soft band + white
  cards + icon-circle index), collection (hairline cards, hover lift, 4:3
  media, pill tag), image_text (1fr/1.22fr + photo frame), stats (white stat
  bar with hairline dividers), cta (gradient band + ghost button), richtext
  (article-body typography incl. tables), plus gallery/faq/tabs/carousel/
  video/pricing/attributes in the same tokens. 68 distinct --sf-tp-* token
  references; breakpoints 900/620 follow the template.
  Section backgrounds use a .sf-canvas chain (0,4,0) so they beat pro-base's
  later-loading .pro-custom-content rules in both static and editor cascades;
  user inline --sf-bg/--sf-fg still win via var() fallbacks; canvas hooks
  (block-id attrs, selection outlines, site-design padding/h1 controls)
  untouched.

Tests: test_trade_pro_blocks_visual_bridge covers distribution (theme file
served verbatim, not platform), full multi-block DOM structure with pro.js
filter hooks, scoped rule/token/breakpoint contracts and editor-safety
red lines. 36/36 in test_theme_registry + test_theme_pro_v3; full suite
453 passed / 11 failed (identical to pre-change baseline).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-28 16:07:39 +08:00
parent d9749044e1
commit c4d9b1075c
2 changed files with 332 additions and 2 deletions
+104
View File
@@ -1018,6 +1018,110 @@ def test_site_html_arrow_matches_macro_library():
assert m and s, 'icon path sources not found'
assert m.group(1) == s.group(1), 'site.html tp_arrow drifted from macro library arrow-right'
def test_trade_pro_blocks_visual_bridge(factory):
# Task 13a:区块样式全量桥接——平台区块(blueprints.render_blocks 的 sf-*
# DOM)在 trade-pro 下获得 sf-tp-* 模板级视觉。三层契约:
# 1) 分发:themes/trade-pro/blocks.css 原文随构建分发(copy_blocks_css
# 替代平台版),构建页 <link> 指向 assets/blocks.css;
# 2) 结构:多类型区块页(layout_mode=replace 纯区块渲染)输出全部 sf-*
# 区块 DOM,pro.js 筛选数据钩子(data-category/data-brand/
# [data-sf-filterable])与 YouTube 白名单改写保留;
# 3) 视觉:.sf-theme-trade-pro 作用域的 sf-* 覆盖规则齐全、--sf-tp-*
# 令牌被引用、模板断点(900/620)齐备,平台基础层(slider/布局栅格/
# 设备断点 820)完整保留,且不触碰编辑器画布钩子(data-block /
# sf-canvas-selected / 区块级 outline)。
import re
from app import blueprints
sid = site(factory)
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
img = 'https://imgs.example/bridge.jpg'
blocks = [
blueprints.new_block('hero', {'title': 'Global Sourcing Hub', 'subtitle': 'TRADE PRO',
'description': 'Verified manufacturers.', 'image': img, 'button': 'Explore'}),
blueprints.new_block('stats', {'items': [{'value': '12+', 'label': 'Years'}, {'value': '40+', 'label': 'Countries'},
{'value': '98%', 'label': 'On time'}, {'value': '120', 'label': 'SKUs'}]}),
blueprints.new_block('features', {'title': 'Why Us', 'items': [{'title': 'QC', 'text': 'Inspection.'},
{'title': 'Logistics', 'text': 'FOB.'}, {'title': 'Support', 'text': 'English desk.'}]}),
blueprints.new_block('collection', {'title': 'Featured', 'subtitle': 'PRODUCTS', 'source': 'product', 'limit': 4}),
blueprints.new_block('image_text', {'title': 'Factory Direct', 'subtitle': 'ABOUT',
'description': 'Own production lines.', 'image': img}),
blueprints.new_block('cta', {'title': 'Request a Quote', 'subtitle': 'Send specifications.'}),
blueprints.new_block('gallery', {'title': 'Workshop', 'images': [{'url': img, 'alt': 'Line'},
{'url': img, 'alt': 'Goods'}, {'url': img, 'alt': 'Lab'}]}),
blueprints.new_block('faq', {'title': 'FAQ', 'items': [{'question': 'MOQ?', 'answer': 'One container.'}]}),
blueprints.new_block('richtext', {'title': 'Terms', 'body': '<p>Incoterms 2020 apply.</p>'}),
blueprints.new_block('carousel', {'title': 'Showcase', 'items': [{'image': img, 'alt': 'Shot', 'caption': 'Line 1'}]}),
blueprints.new_block('video', {'title': 'Plant Tour', 'url': 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'}),
blueprints.new_block('pricing', {'title': 'Plans', 'items': [{'name': 'Bulk', 'price': 'FOB', 'features': ['QC', 'Packing']}]}),
blueprints.new_block('tabs', {'title': 'Details', 'items': [{'label': 'Specs', 'text': 'PE100 raw material.'}]}),
blueprints.new_block('spacer', {'height': 24}),
]
for i in range(2):
made = factory.post(f'/api/sites/{sid}/entries', json={
'type': 'product', 'title': f'Bridge Valve {i}', 'route': f'/product/bridge-{i}/',
'cover': img, 'summary': 'Bridge card.', 'meta': {'fields': {'brand': 'Quanta', 'category': 'Valves'}}})
assert made.status_code in (200, 201), made.text
page = factory.post(f'/api/sites/{sid}/entries', json={
'type': 'page', 'title': 'Bridge Demo', 'route': '/bridge-demo/',
'meta': {'page_template': 'standard', 'layout_mode': 'replace', 'blocks': blocks}})
assert page.status_code in (200, 201), page.text
assert factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version']
# ── 1) 分发:主题版原文(非平台版)随构建输出 ──
html = factory.get(f'/preview/{sid}/{bid}/bridge-demo/').text
assert 'assets/blocks.css' in html
served = factory.get(f'/preview/{sid}/{bid}/assets/blocks.css').text
theme_blocks_css = (ROOT / 'themes' / 'trade-pro' / 'blocks.css').read_text(encoding='utf-8')
platform_blocks_css = (ROOT / 'app' / 'static' / 'blocks.css').read_text(encoding='utf-8')
assert served == theme_blocks_css and served != platform_blocks_css
# ── 2) 结构:全部区块类型渲染 + 平台数据钩子保留 ──
soup = BeautifulSoup(html, 'html.parser')
assert 'sf-theme-trade-pro' in (soup.body.get('class') or [])
assert soup.select_one('main.sf-canvas.pro-custom-content') is not None
for sel in ('section.sf-hero .sf-hero-grid .sf-hero-copy h1', 'section.sf-hero .sf-hero-img img',
'section.sf-stats .sf-stats strong', 'section.sf-features .sf-feature h3',
'section.sf-collection .sf-item-copy h3', 'section.sf-image_text .sf-image-side img',
'section.sf-cta .sf-cta-wrap .sf-button', 'section.sf-gallery .sf-gallery img',
'section.sf-faq details summary', 'section.sf-richtext .sf-prose',
'section.sf-carousel .sf-slides figure.sf-slide', 'section.sf-video .sf-video iframe',
'section.sf-pricing .sf-price strong', 'section.sf-tabs .sf-tab-list details.sf-tab',
'div.sf-spacer'):
assert soup.select_one(sel) is not None, sel
cards = soup.select('.sf-collection[data-sf-filterable] a.sf-item[data-category][data-brand]')
assert len(cards) == 2 and cards[0]['data-brand'] == 'Quanta'
assert 'youtube-nocookie.com/embed/dQw4w9WgXcQ' in html
# ── 3) 视觉桥接:作用域规则 + 令牌 + 断点 + 基础层红线 ──
for scoped in ('.sf-theme-trade-pro .sf-canvas .sf-section.sf-hero{',
'.sf-theme-trade-pro .sf-feature{', '.sf-theme-trade-pro .sf-item{',
'.sf-theme-trade-pro .sf-image-text{', '.sf-theme-trade-pro .sf-container .sf-stats{',
'.sf-theme-trade-pro .sf-canvas .sf-section.sf-cta{', '.sf-theme-trade-pro .sf-gallery{',
'.sf-theme-trade-pro .sf-prose{', '.sf-theme-trade-pro .sf-slides{',
'.sf-theme-trade-pro .sf-container .sf-video{', '.sf-theme-trade-pro .sf-price{',
'.sf-theme-trade-pro .sf-button{'):
assert scoped in served, scoped
for token in ('--sf-tp-color-surface-inverse', '--sf-tp-color-brand-primary', '--sf-tp-color-brand-primary-deep',
'--sf-tp-color-text-inverse', '--sf-tp-color-text-brand', '--sf-tp-color-text-muted',
'--sf-tp-color-surface-muted', '--sf-tp-color-border-subtle', '--sf-tp-radius-md',
'--sf-tp-shadow-md', '--sf-tp-ratio-landscape', '--sf-tp-space-section-y',
'--sf-tp-font-size-2xl', '--sf-tp-button-height-md', '--sf-tp-duration-fast',
'--sf-tp-easing-standard'):
assert f'var({token})' in served, token
assert len(set(re.findall(r'var\(--sf-tp-[a-z0-9-]+\)', served))) >= 25
assert '@media (max-width: 900px)' in served and '@media (max-width: 620px)' in served
# 平台功能层完整保留:slider / 布局栅格 / 设备断点(responsive.py 依赖 820/540 语义)
for base_rule in ('.sf-hero-track{', '.sf-layout-grid{', '.sf-hero-controls{', '@media(max-width:820px)'):
assert base_rule in served, base_rule
# 编辑器画布与区块 outline 红线:主题层不得触碰选择器钩子与区块级 outline
assert 'data-block' not in served and 'sf-canvas-selected' not in served
assert not re.search(r'\.sf-section\.sf-[a-z-]+\s*\{[^}]*outline', served)
# 本构建链接全通
report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
assert not report['errors'] and not report['broken_links']
def test_theme_blocks_css_overrides_platform(tmp_path):
from app import builder
dist = tmp_path / 'assets'; dist.mkdir()