feat: trust_bar + applications_band blocks; homepage now block-driven

- blueprints: register trust_bar (icon/title/text items) and applications_band
  (2x2 dark-band tiles) block types; render with escaped items, ordinal icon
  fallback, 8-item cap; auto-exposed in editor via /api/editor/blocks and
  grouped under business in the studio palette
- blocks.css: platform base layer for both blocks (app/static + trade-pro
  Layer A kept verbatim in sync) + trade-pro bridge vocabulary (icon-circle,
  hairline dividers, navy->brand gradient band, glass tiles, 900/620
  breakpoints, reduced-motion) without touching canvas hooks
- tests: test_trade_pro_home_block_driven covers the full studio-draft
  PUT -> publish -> build chain; 8 block types on the homepage, template
  markers absent under layout_mode=replace, clean build report
- scripts/setup_home_blocks.py: production write path for site 483295c29ea0
  (login -> draft -> publish -> build -> verify); real catalogue counts feed
  the stats block; run after the Docker image picks up the new block types

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-28 18:11:32 +08:00
parent c4d9b1075c
commit 45c39b1695
6 changed files with 405 additions and 1 deletions
+13
View File
@@ -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'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-features" style="--sf-feature-cols:{feature_cols}">{cards}</div>'+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'<div class="sf-trust-item"><span class="sf-trust-icon" aria-hidden="true">{escape(str(item.get("icon") or (i+1)))}</span><strong>{escape(str(item.get("title","")))}</strong><span class="sf-trust-text">{escape(str(item.get("text","")))}</span></div>' for i,item in enumerate(items[:8]) if isinstance(item,dict))
head=f'<div class="sf-section-head"><h2>{title}</h2></div>' if title else ''
parts.append(start+head+f'<div class="sf-trust-bar">{cells}</div>'+end)
elif typ=='applications_band':
items=block.get('items') if isinstance(block.get('items'),list) else []
tiles=''.join(f'<div class="sf-app-tile"><span class="sf-app-icon" aria-hidden="true">{escape(str(item.get("icon") or (i+1)))}</span><h3>{escape(str(item.get("title","")))}</h3><p>{escape(str(item.get("text","")))}</p></div>' for i,item in enumerate(items[:8]) if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2></div></div><div class="sf-applications-band">{tiles}</div>'+end)
elif typ=='stats':
items=block.get('items') if isinstance(block.get('items'),list) else []
cards=''.join(f'<div><strong>{escape(str(item.get("value","")))}</strong><span>{escape(str(item.get("label","")))}</span></div>' for item in items[:12] if isinstance(item,dict))
+20
View File
@@ -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)}}
+1 -1
View File
@@ -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'}
+213
View File
@@ -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())
+106
View File
@@ -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()
+52
View File
@@ -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)}}