diff --git a/app/blueprints.py b/app/blueprints.py
index fd668c2..79d085f 100644
--- a/app/blueprints.py
+++ b/app/blueprints.py
@@ -27,6 +27,8 @@ BLOCKS = {
'pricing':{'name':'服务方案','icon':'$','fields':['title','items'],'defaults':{'title':'服务方案','items':[{'name':'标准方案','price':'联系我们','features':['专业咨询','售后服务'],'href':'/contact/'}]}},
'map':{'name':'位置导航','icon':'⌖','fields':['title','address','url'],'defaults':{'title':'欢迎来访','address':'请填写实际办公地址','url':''}},
'form_link':{'name':'询盘表单入口','icon':'✉','fields':['title','description','button','href'],'defaults':{'title':'提交询盘','description':'欢迎联系我们获取报价和资料','button':'联系销售','href':'/contact/'}},
+ 'trust_bar':{'name':'信任条','icon':'✓','fields':['title','items'],'defaults':{'title':'','items':[{'icon':'✓','title':'Certified quality','text':'Inspected before every shipment'},{'icon':'◎','title':'Global export','text':'Delivering to worldwide markets'},{'icon':'▤','title':'Full documentation','text':'Specs and export files on hand'},{'icon':'✉','title':'Responsive service','text':'Replies within one business day'}]}},
+ 'applications_band':{'name':'应用展示带','icon':'⊞','fields':['title','subtitle','items'],'defaults':{'title':'Built for real-world projects','subtitle':'APPLICATIONS','items':[{'icon':'◉','title':'Water supply','text':'Municipal piping and treatment networks.'},{'icon':'◍','title':'Drainage','text':'Gravity and pressure sewer systems.'},{'icon':'✦','title':'Gas','text':'PE pipelines for fuel gas distribution.'},{'icon':'◆','title':'Mining','text':'Abrasion-resistant slurry and vent lines.'}]}},
}
# Property contracts shared with the no-code editor. Unknown executable types
# are not supported; all values are still verified by validate_blocks.
@@ -308,6 +310,17 @@ def render_blocks(blocks, site, page, entries):
visible_items=sum(1 for item in items[:12] if isinstance(item,dict))
feature_cols=visible_items if 1<=visible_items<=4 else 3
parts.append(start+f'
{title}
{cards}
'+end)
+ elif typ=='trust_bar':
+ # Horizontal trust strip; item icons are free-text glyphs, empty ones
+ # fall back to ordinal markers like the features block.
+ items=block.get('items') if isinstance(block.get('items'),list) else []
+ cells=''.join(f'{escape(str(item.get("icon") or (i+1)))}{escape(str(item.get("title","")))}{escape(str(item.get("text","")))}
' for i,item in enumerate(items[:8]) if isinstance(item,dict))
+ head=f'{title}
' if title else ''
+ parts.append(start+head+f'{cells}
'+end)
+ elif typ=='applications_band':
+ items=block.get('items') if isinstance(block.get('items'),list) else []
+ tiles=''.join(f'{escape(str(item.get("icon") or (i+1)))}{escape(str(item.get("title","")))}
{escape(str(item.get("text","")))}
' for i,item in enumerate(items[:8]) if isinstance(item,dict))
+ parts.append(start+f'{tiles}
'+end)
elif typ=='stats':
items=block.get('items') if isinstance(block.get('items'),list) else []
cards=''.join(f'{escape(str(item.get("value","")))}{escape(str(item.get("label","")))}
' for item in items[:12] if isinstance(item,dict))
diff --git a/app/static/blocks.css b/app/static/blocks.css
index cca3b6f..a2e9f92 100644
--- a/app/static/blocks.css
+++ b/app/static/blocks.css
@@ -69,3 +69,23 @@
@media(prefers-reduced-motion:reduce){.sf-hero-track{scroll-behavior:auto}}
.sf-hero-panel .sf-hero-copy h2{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}
+
+/* Trust bar: horizontal credibility strip under the hero (icon + title + note). */
+.sf-section.sf-trust_bar{padding:38px 0}
+.sf-trust-bar{display:grid;grid-template-columns:repeat(var(--sf-trust-cols,4),minmax(0,1fr))}
+.sf-trust-item{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:10px 20px;border-inline-end:1px solid #dce3eb}
+.sf-trust-item:last-child{border-inline-end:0}
+.sf-trust-icon{display:grid;place-items:center;width:46px;height:46px;border-radius:50%;background:#eef2f8;color:var(--accent,#315ee2);font-size:18px;font-weight:800;margin-bottom:6px}
+.sf-trust-item strong{font-size:15px}
+.sf-trust-text{font-size:13px;opacity:.68;line-height:1.55}
+@media(max-width:820px){.sf-trust-bar{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:24px}.sf-trust-item:nth-child(2n){border-inline-end:0}}
+@media(max-width:540px){.sf-trust-bar{grid-template-columns:minmax(0,1fr)}.sf-trust-item{border-inline-end:0}}
+
+/* Applications band: dark gradient strip with a 2x2 tile grid. */
+.sf-section.sf-applications_band{background:linear-gradient(115deg,#101828,#1d3a6e 78%);color:#f5f7fa}
+.sf-applications-band{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}
+.sf-app-tile{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:26px 24px}
+.sf-app-icon{display:grid;place-items:center;width:44px;height:44px;border-radius:50%;background:rgba(255,255,255,.12);font-size:17px;font-weight:800;margin-bottom:12px}
+.sf-app-tile h3{font-size:20px;margin:0 0 6px}
+.sf-app-tile p{font-size:14px;line-height:1.65;opacity:.75;margin:0}
+@media(max-width:540px){.sf-applications-band{grid-template-columns:minmax(0,1fr)}}
diff --git a/app/static/studio-experience-v829.js b/app/static/studio-experience-v829.js
index 8f94b32..2e4951a 100644
--- a/app/static/studio-experience-v829.js
+++ b/app/static/studio-experience-v829.js
@@ -5,7 +5,7 @@
const pages=new Set(['home','listing','product','article','about','contact']);
const categories={
visual:new Set(['hero','banner','carousel','gallery','image','image_text','image-grid','video','slider']),
- business:new Set(['collection','products','product_list','brand_wall','brands','features','stats','testimonials','faq','contact','form','cta','map']),
+ business:new Set(['collection','products','product_list','brand_wall','brands','features','stats','testimonials','faq','contact','form','cta','map','trust_bar','applications_band']),
advanced:new Set(['layout','columns','tabs','accordion','html-embed','embed','spacer','divider'])
};
function categoryOf(key){for(const [name,ids] of Object.entries(categories))if(ids.has(key))return name;return 'basic'}
diff --git a/scripts/setup_home_blocks.py b/scripts/setup_home_blocks.py
new file mode 100644
index 0000000..800feab
--- /dev/null
+++ b/scripts/setup_home_blocks.py
@@ -0,0 +1,213 @@
+"""Task 13b: switch the HESU Pipe homepage from template-driven to block-driven.
+
+Writes the 8-type block sequence into the site home entry through the real
+studio-draft API (login -> PUT draft -> publish -> build -> verify), so the
+page renders from blocks (layout_mode=replace) with the trade-pro block
+bridge instead of pages/home.html. The template file itself stays untouched.
+
+Run AFTER the Docker image has been rebuilt with the trust_bar /
+applications_band block types, then:
+
+ PYTHONUTF8=1 .venv/Scripts/python scripts/setup_home_blocks.py
+
+Reads SITEFORGE_ADMIN_PASSWORD / SITEFORGE_PORT from the repo .env.
+"""
+from __future__ import annotations
+import json
+import sys
+import time
+import uuid
+from pathlib import Path
+
+import httpx
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+from app import blueprints # noqa: E402 -- local package for block defaults
+
+SITE_ID = '483295c29ea0' # HESU Pipe (real import site, theme trade-pro)
+HOME_ENTRY_ID = 'c8ff1d1a45f3' # route '/' home entry
+
+
+def load_env() -> dict:
+ values: dict[str, str] = {}
+ env_path = ROOT / '.env'
+ if env_path.is_file():
+ for line in env_path.read_text(encoding='utf-8').splitlines():
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ key, val = line.split('=', 1)
+ values[key.strip()] = val.strip()
+ return values
+
+
+def real_stats(entries: list[dict]) -> list[dict]:
+ """Derive stat items from actual catalogue counts - no invented numbers."""
+ products = [e for e in entries if e.get('type') in ('product', 'vehicle')]
+ posts = [e for e in entries if e.get('type') == 'post']
+
+ def field(entry, key):
+ fields = (entry.get('meta') or {}).get('fields') or {}
+ return str(fields.get(key) or '').strip()
+
+ categories = {field(p, 'category') for p in products if field(p, 'category')}
+ brands = {field(p, 'brand') for p in products if field(p, 'brand')}
+ items = [('Products', len(products)), ('Categories', len(categories)),
+ ('Brands', len(brands)), ('News updates', len(posts))]
+ return [{'value': str(count), 'label': label} for label, count in items if count > 0]
+
+
+def build_blocks(entries: list[dict]) -> list[dict]:
+ stats = real_stats(entries)
+ sequence = [
+ blueprints.new_block('hero', {
+ 'title': '{{site.name}}',
+ 'subtitle': 'GLOBAL SOURCING · MADE RELIABLE',
+ 'description': 'Reliable products, transparent specifications and responsive export service.',
+ 'image': '{{page.cover}}',
+ 'button': 'Browse products', 'href': '/products/'}),
+ blueprints.new_block('trust_bar', {}),
+ blueprints.new_block('image_text', {
+ 'title': 'One partner from inquiry to delivery.',
+ 'subtitle': 'ABOUT US',
+ 'description': 'Product specifications, quality control and export documentation in one place.',
+ 'image': '{{page.cover}}',
+ 'button': 'About our company', 'href': '/about/'}),
+ blueprints.new_block('stats', {'items': stats}) if stats else None,
+ blueprints.new_block('collection', {
+ 'title': 'Featured products', 'subtitle': 'TRADE CATALOGUE',
+ 'source': 'product', 'limit': 4, 'columns': 4, 'image_fit': 'cover',
+ 'button': 'Browse all products', 'href': '/products/'}),
+ blueprints.new_block('applications_band', {}),
+ blueprints.new_block('features', {'title': 'Why buyers stay with us', 'items': [
+ {'title': 'Verified sourcing', 'text': 'Suppliers audited against your specifications before the first sample.'},
+ {'title': 'Strict quality control', 'text': 'Pre-shipment inspection with photographic evidence for every batch.'},
+ {'title': 'Export documentation', 'text': 'Certification, packing lists and customs papers prepared in-house.'},
+ {'title': 'Dedicated support', 'text': 'One trade desk contact from inquiry to container loading.'}]}),
+ blueprints.new_block('collection', {
+ 'title': 'Latest updates', 'subtitle': 'COMPANY NEWS',
+ 'source': 'post', 'limit': 3, 'columns': 3, 'image_fit': 'cover',
+ 'button': 'View all news', 'href': '/news/'}),
+ blueprints.new_block('cta', {
+ 'title': 'Send one inquiry, get a matched quote.',
+ 'subtitle': 'Share your specifications and target market; our trade desk replies within one business day.',
+ 'button': 'Request a quote', 'href': '/contact/'}),
+ ]
+ return [block for block in sequence if block is not None]
+
+
+def main() -> int:
+ env = load_env()
+ password = env.get('SITEFORGE_ADMIN_PASSWORD') or ''
+ if not password:
+ print('ERROR: SITEFORGE_ADMIN_PASSWORD missing from .env')
+ return 1
+ port = (env.get('SITEFORGE_PORT') or '8787').strip() or '8787'
+ base = f'http://127.0.0.1:{port}'
+
+ with httpx.Client(base_url=base, timeout=30) as client:
+ health = client.get('/api/health')
+ health.raise_for_status()
+ print('server:', health.json())
+
+ login = client.post('/api/auth/login', json={'username': 'admin', 'password': password})
+ if login.status_code != 200:
+ print('ERROR: login failed:', login.text[:200])
+ return 1
+ client.headers['X-SiteForge-CSRF'] = client.get('/api/auth/me').json()['csrf']
+
+ blocks_catalog = client.get('/api/editor/blocks').json()['blocks']
+ missing = [t for t in ('trust_bar', 'applications_band') if t not in blocks_catalog]
+ if missing:
+ print(f'ERROR: server does not know block types {missing} - rebuild the '
+ 'Docker image with the current source before running this script.')
+ return 1
+
+ entries = client.get(f'/api/sites/{SITE_ID}/entries').json()
+ entry = next((e for e in entries if e.get('route') == '/'), None)
+ if not entry or entry['id'] != HOME_ENTRY_ID:
+ print(f'ERROR: home entry {HOME_ENTRY_ID} (route /) not found on site {SITE_ID}')
+ return 1
+
+ blocks = build_blocks(entries)
+ blueprints.validate_blocks(blocks) # fail fast locally before touching the draft
+
+ state = client.get(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft').json()
+ revision = int(state.get('revision') or 0)
+ print(f"draft state: {state.get('state')} revision={revision}")
+
+ put = client.put(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft',
+ json={'blocks': blocks, 'page_template': 'home',
+ 'layout_mode': 'replace', 'expected_revision': revision})
+ if put.status_code != 200:
+ print('ERROR: draft write failed:', put.status_code, put.text[:300])
+ return 1
+ next_revision = put.json()['revision']
+ print(f'draft saved: revision={next_revision} blocks={len(blocks)} '
+ f'types={[b["type"] for b in blocks]}')
+
+ publish = client.post(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft/publish',
+ json={'expected_revision': next_revision})
+ if publish.status_code != 200:
+ print('ERROR: publish failed:', publish.status_code, publish.text[:300])
+ return 1
+ print('published to content library:', publish.json().get('message'))
+
+ before = {b['id'] for b in client.get(f'/api/sites/{SITE_ID}/builds').json()}
+ key = 'task13b-home-' + uuid.uuid4().hex[:12]
+ job = client.post(f'/api/sites/{SITE_ID}/builds', json={'mirror_images': False},
+ headers={'Idempotency-Key': key})
+ if job.status_code != 202:
+ print('ERROR: build submit failed:', job.status_code, job.text[:300])
+ return 1
+ print('build queued:', job.json())
+
+ build_id = None
+ for _ in range(120):
+ time.sleep(2)
+ rows = client.get(f'/api/sites/{SITE_ID}/builds').json()
+ fresh = [b for b in rows if b['id'] not in before]
+ done = [b for b in fresh if b['status'] == 'complete']
+ if done:
+ build_id = done[0]['id']
+ break
+ failed = [b for b in fresh if b['status'] in ('failed', 'error')]
+ if failed:
+ print('ERROR: build failed:', json.dumps(failed[0], ensure_ascii=False)[:400])
+ return 1
+ if not build_id:
+ print('ERROR: build did not complete in time')
+ return 1
+ print('build complete:', build_id)
+
+ report = client.get(f'/api/sites/{SITE_ID}/builds/{build_id}/report').json()
+ print('report: pages=%s errors=%s broken_links=%s missing_local_images=%s'
+ % (report.get('pages'), len(report.get('errors') or []),
+ len(report.get('broken_links') or []), len(report.get('missing_local_images') or [])))
+ if report.get('errors') or report.get('broken_links'):
+ print('ERROR: build report is not clean')
+ return 1
+
+ page = client.get(f'/preview/{SITE_ID}/{build_id}/')
+ page.raise_for_status()
+ html = page.text
+ # render_blocks always emits class="sf-section sf-{type}" as the opening
+ # attribute, so this is an exact section-level check.
+ absent = [t for t in ('hero', 'trust_bar', 'image_text', 'stats',
+ 'applications_band', 'features', 'cta')
+ if f'class="sf-section sf-{t}"' not in html]
+ if html.count('class="sf-section sf-collection"') < 2:
+ absent.append('collection x2')
+ gone = [m for m in ('sf-tp-hero', 'sf-tp-trustbar', 'sf-tp-two-col',
+ 'sf-tp-applications', 'sf-tp-feature-grid', 'sf-tp-cta') if m in html]
+ print('block sections missing:', absent or 'none')
+ print('template markers still present:', gone or 'none')
+ if absent or gone:
+ print('ERROR: homepage is not block-driven as expected')
+ return 1
+ print('PASS: homepage is now block-driven (layout_mode=replace), build clean.')
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/tests/test_theme_registry.py b/tests/test_theme_registry.py
index 5b9838a..5a78a71 100644
--- a/tests/test_theme_registry.py
+++ b/tests/test_theme_registry.py
@@ -1122,6 +1122,112 @@ def test_trade_pro_blocks_visual_bridge(factory):
assert not report['errors'] and not report['broken_links']
+def test_trade_pro_home_block_driven(factory):
+ # Task 13b:首页从模板驱动切换为区块驱动(layout_mode=replace)。
+ # 序列对照 home.html 模板设计:hero → trust_bar(新增)→ image_text+stats →
+ # collection(product,4) → applications_band(新增)→ features →
+ # collection(post,3) → cta。写入走真实 studio-draft 链路(PUT 草稿 →
+ # publish 发布 → 构建),与运维脚本 scripts/setup_home_blocks.py 同一条
+ # 代码路径。home.html 模板文件保留(其他站点仍可用),仅本站首页 replace。
+ import io
+ from app import blueprints
+ sid = site(factory)
+ assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
+ img = 'https://imgs.example/hesu-plant.jpg'
+ batch = [{'type': 'product', 'title': f'HDPE Pipe PE100 {i}',
+ 'summary': f'Pressure pipe {i} for municipal water supply.', 'cover': img,
+ 'meta': {'fields': {'brand': 'HESU', 'category': 'Pipes'}}} for i in range(4)]
+ batch += [{'type': 'post', 'title': f'Export Update {i}',
+ 'summary': f'Dock loading report {i}.'} for i in range(3)]
+ payload = json.dumps(batch).encode('utf-8')
+ imported = factory.post(f'/api/sites/{sid}/import',
+ files={'file': ('home-batch.json', io.BytesIO(payload), 'application/json')})
+ assert imported.status_code == 200, imported.text[:300]
+ assert factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
+ home = next(e for e in factory.get(f'/api/sites/{sid}/entries').json() if e['route'] == '/')
+ blocks = [
+ blueprints.new_block('hero', {'title': 'Reliable piping systems, exported worldwide',
+ 'subtitle': 'GLOBAL SOURCING · MADE RELIABLE',
+ 'description': 'Specifications, certification and packaging published with every listing.',
+ 'image': img, 'button': 'Browse products', 'href': '/products/'}),
+ blueprints.new_block('trust_bar', {}),
+ blueprints.new_block('image_text', {'title': 'One partner from inquiry to delivery.',
+ 'subtitle': 'ABOUT US', 'image': img,
+ 'description': 'Product specifications, quality control and export documentation in one place.',
+ 'button': 'About our company', 'href': '/about/'}),
+ blueprints.new_block('stats', {'items': [{'value': '48', 'label': 'Products'},
+ {'value': '12', 'label': 'Categories'},
+ {'value': '30', 'label': 'Markets served'},
+ {'value': '10', 'label': 'Export years'}]}),
+ blueprints.new_block('collection', {'title': 'Featured products', 'subtitle': 'TRADE CATALOGUE',
+ 'source': 'product', 'limit': 4, 'columns': 4, 'image_fit': 'cover',
+ 'button': 'Browse all products', 'href': '/products/'}),
+ blueprints.new_block('applications_band', {}),
+ blueprints.new_block('features', {'title': 'Why buyers stay with us', 'items': [
+ {'title': 'Verified sourcing', 'text': 'Suppliers audited against your specifications before the first sample.'},
+ {'title': 'Strict quality control', 'text': 'Pre-shipment inspection with photographic evidence for every batch.'},
+ {'title': 'Export documentation', 'text': 'Certification, packing lists and customs papers prepared in-house.'},
+ {'title': 'Dedicated support', 'text': 'One trade desk contact from inquiry to container loading.'}]}),
+ blueprints.new_block('collection', {'title': 'Latest updates', 'subtitle': 'COMPANY NEWS',
+ 'source': 'post', 'limit': 3, 'columns': 3, 'image_fit': 'cover',
+ 'button': 'View all news', 'href': '/news/'}),
+ blueprints.new_block('cta', {'title': 'Send one inquiry, get a matched quote.',
+ 'subtitle': 'Share your specifications and target market; our trade desk replies within one business day.',
+ 'button': 'Request a quote', 'href': '/contact/'}),
+ ]
+ put = factory.put(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft',
+ json={'blocks': blocks, 'page_template': 'home', 'layout_mode': 'replace', 'expected_revision': 0})
+ assert put.status_code == 200, put.text
+ pub = factory.post(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft/publish', json={'expected_revision': 1})
+ assert pub.status_code == 200, pub.text
+ state = factory.get(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft').json()
+ assert state['state'] == 'published' and state['layout_mode'] == 'replace'
+
+ bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version']
+ html = factory.get(f'/preview/{sid}/{bid}/').text
+ soup = BeautifulSoup(html, 'html.parser')
+ # ── 8 种区块全部在场(在画布容器内,编辑器结构契约不破坏)──
+ canvas = soup.select_one('main.sf-canvas.pro-custom-content')
+ assert canvas is not None
+ for sel in ('section.sf-hero', 'section.sf-trust_bar', 'section.sf-image_text', 'section.sf-stats',
+ 'section.sf-applications_band', 'section.sf-features', 'section.sf-cta'):
+ assert canvas.select_one(sel) is not None, sel
+ collections = canvas.select('section.sf-collection')
+ assert len(collections) == 2
+ assert len(collections[0].select('a.sf-item')) == 4 # 产品精选 4 卡
+ assert len(collections[1].select('a.sf-item')) == 3 # 新闻 3 卡
+ assert 'HDPE Pipe PE100 0' in collections[0].get_text()
+ assert 'Export Update' in collections[1].get_text()
+ # ── 新区块结构契约:信任条(icon-circle+标题+说明+分隔线)与应用带(2×2 瓦片)──
+ trust = canvas.select('.sf-trust-bar .sf-trust-item')
+ assert len(trust) == 4
+ assert all(t.select_one('.sf-trust-icon') and t.find('strong') and t.select_one('.sf-trust-text') for t in trust)
+ assert 'Certified quality' in trust[0].get_text() and 'Responsive service' in trust[3].get_text()
+ tiles = canvas.select('.sf-applications-band .sf-app-tile')
+ assert len(tiles) == 4
+ assert all(t.select_one('.sf-app-icon') and t.find('h3') and t.find('p') for t in tiles)
+ assert 'Water supply' in tiles[0].get_text() and 'Mining' in tiles[3].get_text()
+ # ── 模板不再渲染:home.html 的模板级标记全数缺席(chrome 的 sf-tp-btn 不在此列)──
+ for marker in ('sf-tp-hero', 'sf-tp-trustbar', 'sf-tp-two-col', 'sf-tp-card-grid',
+ 'sf-tp-applications', 'sf-tp-feature-grid', 'sf-tp-cta'):
+ assert marker not in html, marker
+ assert len(soup.find_all('h1')) == 1 # 区块 hero 提供唯一 h1,模板缺席不重复
+ # ── 桥接层语汇:新区块的 trade-pro 视觉规则 + sf-tp 令牌随构建分发 ──
+ served = factory.get(f'/preview/{sid}/{bid}/assets/blocks.css').text
+ for rule in ('.sf-theme-trade-pro .sf-canvas .sf-section.sf-trust_bar{',
+ '.sf-theme-trade-pro .sf-trust-item{',
+ '.sf-theme-trade-pro .sf-canvas .sf-section.sf-applications_band{',
+ '.sf-theme-trade-pro .sf-app-tile{'):
+ assert rule in served, rule
+ assert 'var(--sf-tp-color-surface-inverse)' in served
+ assert 'var(--sf-tp-color-text-brand)' in served
+ # 编辑器画布红线(13a 契约延续):新区块样式不触碰画布钩子
+ assert 'data-block' not in served and 'sf-canvas-selected' not in served
+ # ── 构建报告零错误 ──
+ report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
+ assert not report['errors'] and not report['broken_links'] and not report['missing_local_images']
+
+
def test_theme_blocks_css_overrides_platform(tmp_path):
from app import builder
dist = tmp_path / 'assets'; dist.mkdir()
diff --git a/themes/trade-pro/blocks.css b/themes/trade-pro/blocks.css
index 9b43cb2..5723e87 100644
--- a/themes/trade-pro/blocks.css
+++ b/themes/trade-pro/blocks.css
@@ -17,6 +17,9 @@
cta → sf-tp-cta(深色渐变带 + 幽灵按钮)、
richtext → sf-tp-article-body 排版层;gallery/carousel/video/pricing/
tabs 按同一令牌语汇补齐(cms 无一一对应组件)。
+ Task 13b 新增:trust_bar → sf-tp-trustbar(白底横带 + icon-circle +
+ 发丝分隔线)、applications_band → sf-tp-applications(深色渐变带 +
+ 2×2 玻璃瓦片)——支撑首页从模板驱动切换为区块驱动(layout_mode=replace)。
级联纪律:
- 节级底色用 .sf-canvas 链(0,4,0)压过 pro-base 晚载的
.pro-custom-content .sf-section(:nth-of-type(even))(静态构建 blocks 先于
@@ -99,6 +102,26 @@
.sf-hero-panel .sf-hero-copy h2{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}
+/* Trust bar: horizontal credibility strip under the hero (icon + title + note). */
+.sf-section.sf-trust_bar{padding:38px 0}
+.sf-trust-bar{display:grid;grid-template-columns:repeat(var(--sf-trust-cols,4),minmax(0,1fr))}
+.sf-trust-item{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:10px 20px;border-inline-end:1px solid #dce3eb}
+.sf-trust-item:last-child{border-inline-end:0}
+.sf-trust-icon{display:grid;place-items:center;width:46px;height:46px;border-radius:50%;background:#eef2f8;color:var(--accent,#315ee2);font-size:18px;font-weight:800;margin-bottom:6px}
+.sf-trust-item strong{font-size:15px}
+.sf-trust-text{font-size:13px;opacity:.68;line-height:1.55}
+@media(max-width:820px){.sf-trust-bar{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:24px}.sf-trust-item:nth-child(2n){border-inline-end:0}}
+@media(max-width:540px){.sf-trust-bar{grid-template-columns:minmax(0,1fr)}.sf-trust-item{border-inline-end:0}}
+
+/* Applications band: dark gradient strip with a 2x2 tile grid. */
+.sf-section.sf-applications_band{background:linear-gradient(115deg,#101828,#1d3a6e 78%);color:#f5f7fa}
+.sf-applications-band{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}
+.sf-app-tile{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:26px 24px}
+.sf-app-icon{display:grid;place-items:center;width:44px;height:44px;border-radius:50%;background:rgba(255,255,255,.12);font-size:17px;font-weight:800;margin-bottom:12px}
+.sf-app-tile h3{font-size:20px;margin:0 0 6px}
+.sf-app-tile p{font-size:14px;line-height:1.65;opacity:.75;margin:0}
+@media(max-width:540px){.sf-applications-band{grid-template-columns:minmax(0,1fr)}}
+
/* ════════ Layer B:trade-pro 视觉桥接(sf-tp-* 模板语汇 → sf-* 区块)════════ */
/* ── 画布守护(V8.33 既有约定):区块节不溢出栅格列、贴入图片不撑破版心;
@@ -225,6 +248,27 @@
.sf-theme-trade-pro .sf-attribute strong{color:var(--sf-tp-color-text-primary)}
.sf-theme-trade-pro .sf-map-address{color:var(--sf-tp-color-text-secondary);font-size:var(--sf-tp-font-size-md)}
+/* ── trust_bar → sf-tp-trustbar(Task 13b):白底横带 + icon-circle(brand-soft 底
+ 品牌字)+ 列间发丝分隔线(cms 首页信任条语汇)── */
+.sf-theme-trade-pro .sf-canvas .sf-section.sf-trust_bar{background:var(--sf-bg,var(--sf-tp-color-surface-default));padding-block:calc(var(--sf-pad,var(--sf-tp-space-10)) - var(--sf-tp-space-2))}
+.sf-theme-trade-pro .sf-trust-item{padding:var(--sf-tp-space-2) var(--sf-tp-space-4);gap:var(--sf-tp-space-1);border-inline-end:var(--sf-tp-border-width-hairline) solid var(--sf-tp-color-border-subtle)}
+.sf-theme-trade-pro .sf-trust-item:last-child{border-inline-end:0}
+.sf-theme-trade-pro .sf-trust-icon{width:var(--sf-tp-icon-size-xl);height:var(--sf-tp-icon-size-xl);margin-block-end:var(--sf-tp-space-2);border-radius:var(--sf-tp-radius-full);background:var(--sf-tp-color-surface-brand-soft);color:var(--sf-tp-color-text-brand);font-size:var(--sf-tp-font-size-md);box-shadow:var(--sf-tp-shadow-xs)}
+.sf-theme-trade-pro .sf-trust-item strong{color:var(--sf-tp-color-text-primary);font-size:var(--sf-tp-font-size-md)}
+.sf-theme-trade-pro .sf-trust-text{color:var(--sf-tp-color-text-muted);opacity:1;font-size:var(--sf-tp-font-size-sm);line-height:var(--sf-tp-line-height-normal)}
+
+/* ── applications_band → sf-tp-applications(Task 13b):藏青→品牌蓝渐变带 +
+ 2×2 玻璃瓦片(inverse 透底 + 发丝透边 + 品牌圆标,cms 应用带语汇)── */
+.sf-theme-trade-pro .sf-canvas .sf-section.sf-applications_band{background:var(--sf-bg,linear-gradient(115deg,var(--sf-tp-color-surface-inverse),var(--sf-tp-color-brand-primary-deep) 78%));color:var(--sf-fg,var(--sf-tp-color-text-inverse))}
+.sf-theme-trade-pro .sf-section.sf-applications_band .sf-kicker{color:var(--sf-tp-color-text-inverse-muted)}
+.sf-theme-trade-pro .sf-section.sf-applications_band .sf-section-head h2{color:var(--sf-tp-color-text-inverse)}
+.sf-theme-trade-pro .sf-applications-band{gap:var(--sf-tp-space-4)}
+.sf-theme-trade-pro .sf-app-tile{background:color-mix(in srgb,var(--sf-tp-color-text-inverse) 7%,transparent);border:var(--sf-tp-border-width-hairline) solid color-mix(in srgb,var(--sf-tp-color-text-inverse) 18%,transparent);border-radius:var(--sf-tp-card-border-radius);padding:var(--sf-tp-space-5);transition:transform var(--sf-tp-duration-fast) var(--sf-tp-easing-standard),box-shadow var(--sf-tp-duration-fast) var(--sf-tp-easing-standard)}
+.sf-theme-trade-pro .sf-app-tile:hover{transform:translateY(calc(-1 * var(--sf-tp-space-1)));box-shadow:var(--sf-tp-shadow-md)}
+.sf-theme-trade-pro .sf-app-icon{width:var(--sf-tp-icon-size-xl);height:var(--sf-tp-icon-size-xl);margin-block-end:var(--sf-tp-space-3);border-radius:var(--sf-tp-radius-full);background:var(--sf-tp-color-surface-brand);color:var(--sf-tp-color-text-on-brand);font-size:var(--sf-tp-font-size-md)}
+.sf-theme-trade-pro .sf-app-tile h3{color:var(--sf-tp-color-text-inverse);font-size:var(--sf-tp-font-size-md);margin-block-end:var(--sf-tp-space-1)}
+.sf-theme-trade-pro .sf-app-tile p{color:color-mix(in srgb,var(--sf-tp-color-text-inverse) 80%,transparent);opacity:1;font-size:var(--sf-tp-font-size-sm);line-height:var(--sf-tp-line-height-normal);margin:0}
+
/* ── 桥接层响应式(断点随模板:900 栅格堆叠 / 620 紧凑;Layer A 的 820/540
设备控制语义由 responsive.py 继续接管 padding/显隐)── */
@media (max-width: 900px){
@@ -234,6 +278,8 @@
.sf-theme-trade-pro .sf-collection{grid-template-columns:repeat(2,minmax(0,1fr))}
.sf-theme-trade-pro .sf-gallery{grid-template-columns:repeat(2,1fr)}
.sf-theme-trade-pro .sf-cta-wrap{align-items:flex-start;flex-direction:column}
+ .sf-theme-trade-pro .sf-trust-bar{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:var(--sf-tp-space-6)}
+ .sf-theme-trade-pro .sf-trust-item:nth-child(2n){border-inline-end:0}
}
@media (max-width: 620px){
.sf-theme-trade-pro .sf-section.sf-hero .sf-hero-grid{gap:var(--sf-tp-space-4)}
@@ -251,12 +297,18 @@
.sf-theme-trade-pro .sf-section-head>a{display:inline-block;margin-block-start:var(--sf-tp-space-2)}
.sf-theme-trade-pro .sf-cta-wrap p{font-size:var(--sf-tp-font-size-sm)}
.sf-theme-trade-pro .sf-price{padding:var(--sf-tp-card-padding-md)}
+ .sf-theme-trade-pro .sf-trust-bar{grid-template-columns:minmax(0,1fr)}
+ .sf-theme-trade-pro .sf-trust-item{border-inline-end:0;padding:var(--sf-tp-space-2)}
+ .sf-theme-trade-pro .sf-applications-band{gap:var(--sf-tp-space-3)}
+ .sf-theme-trade-pro .sf-app-tile{padding:var(--sf-tp-space-4)}
}
/* 动效尊重系统偏好(Layer A 同款契约,覆盖本层新增过渡)*/
@media(prefers-reduced-motion:reduce){
.sf-theme-trade-pro .sf-item,.sf-theme-trade-pro .sf-item-img img,.sf-theme-trade-pro .sf-gallery img,.sf-theme-trade-pro .sf-button{transition:none}
.sf-theme-trade-pro .sf-item:hover,.sf-theme-trade-pro .sf-button:hover{transform:none}
.sf-theme-trade-pro .sf-item:hover .sf-item-img img,.sf-theme-trade-pro .sf-gallery img:hover{transform:none}
+ .sf-theme-trade-pro .sf-app-tile{transition:none}
+ .sf-theme-trade-pro .sf-app-tile:hover{transform:none}
}
/* 画布列内区块的移动端收边(V8.33 既有约定)*/
@media(max-width:640px){.sf-theme-trade-pro .sf-layout-column{min-width:0}.sf-theme-trade-pro .sf-canvas .sf-section{padding-inline:clamp(12px,4vw,24px)}}