@my-life-buddies/cli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -53
- package/dist/bin/core.js +936 -447
- package/dist/bin/core.js.map +4 -4
- package/dist/bin/preview.js +147 -12
- package/dist/bin/preview.js.map +3 -3
- package/dist/web/app.css +8 -0
- package/dist/web/app.js +7 -2
- package/dist/web/index.html +11 -3
- package/dist/web/widget-delivery.js +28 -0
- package/dist/web/widgets.css +31 -0
- package/dist/web/widgets.js +127 -0
- package/package.json +3 -3
- package/resources/README.md +3 -1
- package/resources/agent-template/package-lock.json +4 -4
- package/resources/agent-template/package.json +1 -1
- package/resources/buddy-creator/INSTALL.md +2 -2
- package/resources/buddy-creator/PATCHES.md +21 -1
- package/resources/buddy-creator/SKILL.md +16 -6
- package/resources/buddy-creator/agents/openai.yaml +1 -1
- package/resources/buddy-creator/assets/preview/index.html +2 -0
- package/resources/buddy-creator/assets/preview/widgets.css +1 -0
- package/resources/buddy-creator/assets/preview/widgets.js +168 -0
- package/resources/buddy-creator/assets/widget-reference/conversation.png +0 -0
- package/resources/buddy-creator/assets/widget-reference/expanded.png +0 -0
- package/resources/buddy-creator/assets/widget-v2/app.js +62 -0
- package/resources/buddy-creator/assets/widget-v2/style.css +1 -0
- package/resources/buddy-creator/assets/widget-v3/app.js +65 -0
- package/resources/buddy-creator/assets/widget-v3/style.css +103 -0
- package/resources/buddy-creator/assets/widget-v4/app.js +66 -0
- package/resources/buddy-creator/assets/widget-v4/style.css +187 -0
- package/resources/buddy-creator/references/artifact-schema.md +2 -0
- package/resources/buddy-creator/references/host-guide.md +12 -6
- package/resources/buddy-creator/references/recovery.md +6 -3
- package/resources/buddy-creator/references/service.md +2 -0
- package/resources/buddy-creator/references/widgets-protocol.md +83 -0
- package/resources/buddy-creator/references/widgets.md +99 -0
- package/resources/buddy-creator/scripts/buddy_core.py +75 -13
- package/resources/buddy-creator/scripts/completion.py +64 -8
- package/resources/buddy-creator/scripts/preview.py +46 -6
- package/resources/buddy-creator/scripts/widget_render.py +66 -0
- package/resources/buddy-creator/scripts/widget_render_v1.py +176 -0
- package/resources/buddy-creator/scripts/widget_render_v2.py +98 -0
- package/resources/buddy-creator/scripts/widget_render_v3.py +86 -0
- package/resources/buddy-creator/scripts/widget_render_v4.py +121 -0
- package/resources/buddy-creator/scripts/widgets.py +227 -0
- package/resources/buddy-creator/version.json +1 -1
- package/resources/buddy-creator.manifest.json +121 -26
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Deterministic, portable read-only prototypes. No supplied executable H5."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import buddy_core as core
|
|
8
|
+
|
|
9
|
+
CSS = '''*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;font:400 16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#222;background:#fff}button{font:inherit;color:inherit;cursor:pointer}button:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:3px}header{height:88px;display:flex;align-items:center;justify-content:space-between;padding:28px 20px 12px;font-weight:500}main{padding:12px 16px 100px}.bubble{background:#f1f3f4;border-radius:18px;padding:14px 16px;margin:12px 0;max-width:calc(100% - 36px);white-space:pre-wrap;overflow-wrap:anywhere}.user{margin-left:auto;background:#e5f6fc}.buddy{margin-left:32px}.chat-card{margin:16px 0 0 32px;border:1px solid #eee;border-radius:18px;padding:16px;max-width:calc(100% - 32px)}h1,h2,h3,p{margin:0}h1,h2,h3{font-weight:500;font-size:var(--title-size)}.chat-card p{margin:12px 0;white-space:pre-wrap;overflow-wrap:anywhere}.open{width:100%;background:#f1f3f4;border:0;border-radius:12px;padding:9px}.caption{color:#73777c;font-size:13px;margin-top:12px}.composer{position:fixed;bottom:20px;left:16px;right:16px;background:#f7f8f9;border:1px solid #eee;border-radius:14px;padding:12px;color:#767b80;font-size:14px}.overlay{position:fixed;inset:0;background:#0003;display:grid;place-items:center}.overlay[hidden],.detail[hidden]{display:none}.sheet{width:calc(100% - 32px);height:65%;background:#fff;border-radius:24px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 20px 80px #0001}.sheet-head{display:flex;align-items:center;gap:12px;padding:16px;flex:none;border-bottom:1px solid #f1f1f1}.sheet-head h2{flex:1;text-align:center;overflow-wrap:anywhere}.close{border:1px solid #eee;background:#fff;border-radius:50%;width:36px;height:36px;flex:none}.spacer{width:36px}.scroll{overflow-y:auto;overscroll-behavior:contain;padding:16px;flex:1;min-height:0}.tabs,.filters{display:flex;gap:8px;overflow-x:auto;padding:2px 2px 14px;flex:none}.tabs button,.filters button{flex:none;border:1px solid #eceff1;background:#f1f3f4;border-radius:28px;padding:10px 16px;font-size:16px}.tabs button[aria-pressed=true],.filters button[aria-pressed=true]{border:2px solid var(--accent);padding:9px 15px;font-weight:500}.section{margin:8px 0 22px}.section h3,summary{margin:0 0 12px;font-size:16px;font-weight:500}summary{cursor:pointer}.row{padding:10px 0;border-bottom:1px solid #f2f3f4;overflow-wrap:anywhere}.row-label{color:#777;margin-bottom:4px}.row-value{white-space:pre-wrap}.detail-open{border:0;background:#f1f3f4;border-radius:8px;padding:6px 10px;margin-top:8px;font-size:14px}.cards .row{padding:14px;border-radius:14px;background:#f7f8f9;margin-bottom:10px;border:0}.timeline .row{border-left:2px solid var(--accent);padding-left:16px;margin-left:4px;border-bottom:0}.detail{position:absolute;inset:0;background:#0003;display:grid;place-items:center}.detail-box{width:calc(100% - 32px);max-height:60%;overflow:auto;border-radius:20px;padding:20px;background:#fff;white-space:pre-wrap;overflow-wrap:anywhere}.detail-box button{float:right}.note{font-size:14px;color:#73777c;margin-top:20px}.screen-title{max-width:75%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(prefers-reduced-motion:no-preference){button{transition:background .15s ease}}'''
|
|
10
|
+
|
|
11
|
+
JS = '''"use strict";
|
|
12
|
+
const data=JSON.parse(document.getElementById("prototype-data").textContent);
|
|
13
|
+
const params=new URLSearchParams(location.search);
|
|
14
|
+
const index=Math.max(0,data.states.findIndex(s=>s.id===params.get("state")));
|
|
15
|
+
const sample=data.states[index], interactions=data.interactions;
|
|
16
|
+
const make=(tag,text,cls)=>{const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e;};
|
|
17
|
+
document.documentElement.style.setProperty("--accent",data.design.accent);
|
|
18
|
+
document.documentElement.style.setProperty("--title-size",data.design.titleSize+"px");
|
|
19
|
+
document.querySelector(".screen-title").textContent=data.name;
|
|
20
|
+
const main=document.querySelector("main");
|
|
21
|
+
main.append(make("div",sample.userMessage,"bubble user"),make("div",sample.buddyMessage,"bubble buddy"));
|
|
22
|
+
const card=make("article",undefined,"chat-card");card.append(make("h1",data.name),make("p",sample.summary));
|
|
23
|
+
const open=make("button","去查看","open");card.append(open,make("div","内容随对话更新","caption"));main.append(card);
|
|
24
|
+
const overlay=document.querySelector(".overlay"),scroll=document.querySelector(".scroll"),detail=document.querySelector(".detail");
|
|
25
|
+
document.querySelector(".sheet-head h2").textContent=data.name;
|
|
26
|
+
scroll.classList.add(data.design.layout);
|
|
27
|
+
let selected=interactions.includes("tabs")?sample.sections[0].id:"all";
|
|
28
|
+
function render(){scroll.replaceChildren();
|
|
29
|
+
for(const mode of ["tabs","filter"]){if(!interactions.includes(mode))continue;
|
|
30
|
+
const nav=make("nav",undefined,mode==="tabs"?"tabs":"filters");nav.setAttribute("aria-label",mode==="tabs"?"切换阅读分组":"筛选已有内容");
|
|
31
|
+
const options=mode==="filter"?[{id:"all",title:"全部"},...sample.sections]:sample.sections;
|
|
32
|
+
for(const section of options){const b=make("button",section.title);b.setAttribute("aria-pressed",String(section.id===selected));b.onclick=()=>{selected=section.id;render();};nav.append(b);}scroll.append(nav);}
|
|
33
|
+
for(const section of sample.sections){if(selected!=="all"&§ion.id!==selected)continue;
|
|
34
|
+
const group=make(interactions.includes("collapse")?"details":"section",undefined,"section");
|
|
35
|
+
if(group.tagName==="DETAILS")group.open=true;
|
|
36
|
+
group.append(make(group.tagName==="DETAILS"?"summary":"h3",section.title));
|
|
37
|
+
for(const row of section.rows){const cell=make("div",undefined,"row");cell.append(make("div",row.label,"row-label"),make("div",row.value,"row-value"));
|
|
38
|
+
if(row.detail){const b=make("button","查看说明","detail-open");b.onclick=()=>{document.querySelector(".detail-text").textContent=row.detail;detail.hidden=false;detail.querySelector("button").focus();detail.returnFocus=b;};cell.append(b);}group.append(cell);}scroll.append(group);}
|
|
39
|
+
scroll.append(make("p","如需修改内容,请回到对话中告诉搭子。","note"));}
|
|
40
|
+
function closeDetail(){detail.hidden=true;if(detail.returnFocus)detail.returnFocus.focus();}
|
|
41
|
+
function closeSheet(){closeDetail();overlay.hidden=true;open.focus();}
|
|
42
|
+
open.onclick=()=>{overlay.hidden=false;overlay.querySelector("button").focus();};
|
|
43
|
+
document.querySelector(".sheet-close").onclick=closeSheet;
|
|
44
|
+
document.querySelector(".detail-close").onclick=closeDetail;
|
|
45
|
+
document.addEventListener("keydown",e=>{if(e.key==="Escape"){if(!detail.hidden)closeDetail();else if(!overlay.hidden)closeSheet();}
|
|
46
|
+
if(e.key==="Tab"&&!overlay.hidden){const area=detail.hidden?document.querySelector(".sheet"):document.querySelector(".detail-box");const nodes=[...area.querySelectorAll("button,summary")].filter(n=>n.getClientRects().length);const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}}});
|
|
47
|
+
render();overlay.hidden=params.get("view")!=="expanded";
|
|
48
|
+
'''
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _svg(data, sample, expanded):
|
|
52
|
+
"""Export two annotated visual views per data state; HTML is the full scrollable view."""
|
|
53
|
+
d = data['design']
|
|
54
|
+
w, h = d['screenWidth'], d['screenHeight']
|
|
55
|
+
parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="%s" height="%s" viewBox="0 0 %s %s"><rect width="100%%" height="100%%" fill="white"/><g font-family="-apple-system, sans-serif" font-size="16" fill="#222">' % (w, h, w, h)]
|
|
56
|
+
def rect(x, y, width, height, fill, radius=16):
|
|
57
|
+
parts.append('<rect x="%s" y="%s" width="%s" height="%s" rx="%s" fill="%s"/>' % (x, y, width, height, radius, fill))
|
|
58
|
+
def lines(text, x, y, width, limit=4, size=16, weight=400):
|
|
59
|
+
chars, current, measured = [], '', 0
|
|
60
|
+
for ch in text:
|
|
61
|
+
measure = size if ord(ch) > 127 else size * .55
|
|
62
|
+
if measured + measure > width or ch == '\n':
|
|
63
|
+
chars.append(current); current, measured = '', 0
|
|
64
|
+
if ch != '\n':
|
|
65
|
+
current += ch; measured += measure
|
|
66
|
+
if current:
|
|
67
|
+
chars.append(current)
|
|
68
|
+
if len(chars) > limit:
|
|
69
|
+
chars = chars[:limit]; chars[-1] = chars[-1][:-1] + '…'
|
|
70
|
+
for i, line in enumerate(chars):
|
|
71
|
+
parts.append('<text x="%s" y="%s" font-size="%s" font-weight="%s">%s</text>' % (x, y + i * (size + 8), size, weight, html.escape(line)))
|
|
72
|
+
return y + len(chars) * (size + 8)
|
|
73
|
+
lines('9:41', 24, 28, 70, 1, 14, 500)
|
|
74
|
+
lines(data['name'], 48, 73, w-96, 1, 16, 500)
|
|
75
|
+
rect(64, 106, w-80, 82, '#e5f6fc'); lines(sample['userMessage'], 80, 133, w-112, 2)
|
|
76
|
+
rect(48, 204, w-64, 106, '#f1f3f4'); lines(sample['buddyMessage'], 64, 230, w-96, 3)
|
|
77
|
+
rect(48, 326, w-64, 178, '#fafafa'); lines(data['name'], 64, 354, w-96, 1, d['titleSize'], 500)
|
|
78
|
+
lines(sample['summary'], 64, 386, w-96, 2)
|
|
79
|
+
rect(64, 442, w-96, 42, '#eff1f3', 10); lines('去查看', w/2-24, 469, 80, 1)
|
|
80
|
+
rect(16, h-80, w-32, 52, '#f6f7f8', 12); lines('在对话中告诉搭子你的修改', 32, h-48, w-64, 1, 14)
|
|
81
|
+
if expanded:
|
|
82
|
+
parts.append('<rect width="100%" height="100%" fill="#000" opacity=".18"/>')
|
|
83
|
+
height = h*.65; top = (h-height)/2
|
|
84
|
+
rect(16, top, w-32, height, '#fff', 24)
|
|
85
|
+
lines('×', 32, top+38, 28, 1, 20); lines(data['name'], 76, top+37, w-128, 1, d['titleSize'], 500)
|
|
86
|
+
parts.append('<defs><clipPath id="content"><rect x="32" y="%s" width="%s" height="%s"/></clipPath></defs><g clip-path="url(#content)">' % (top+64, w-64, height-100))
|
|
87
|
+
y = top+89
|
|
88
|
+
if 'tabs' in data['interactions'] or 'filter' in data['interactions']:
|
|
89
|
+
x=32
|
|
90
|
+
for section in sample['sections'][:3]:
|
|
91
|
+
rect(x, y-21, 84, 42, '#f1f3f4', 20); lines(section['title'], x+10, y+5, 64, 1); x+=92
|
|
92
|
+
y+=54
|
|
93
|
+
sections = sample['sections'][:1] if 'tabs' in data['interactions'] else sample['sections']
|
|
94
|
+
for section in sections:
|
|
95
|
+
y=lines(section['title'], 32, y, w-64, 2, 16, 500)+8
|
|
96
|
+
for row in section['rows']:
|
|
97
|
+
x, width = 32, w-64
|
|
98
|
+
if d['layout'] == 'cards':
|
|
99
|
+
rect(32, y-18, w-64, 116 if row.get('detail') else 92, '#f7f8f9', 14)
|
|
100
|
+
x, width, y = 46, w-92, y+4
|
|
101
|
+
elif d['layout'] == 'timeline':
|
|
102
|
+
rect(36, y-16, 2, 84, d['accent'], 0)
|
|
103
|
+
x, width = 52, w-84
|
|
104
|
+
parts.append('<g fill="#777">')
|
|
105
|
+
y=lines(row['label'], x, y, width, 1)+2
|
|
106
|
+
parts.append('</g>')
|
|
107
|
+
y=lines(row['value'], x, y, width, 2)
|
|
108
|
+
if row.get('detail'):
|
|
109
|
+
y=lines('查看说明', x, y+4, width, 1, 14)
|
|
110
|
+
y+=22
|
|
111
|
+
parts.append('</g>')
|
|
112
|
+
lines('上下滚动阅读 · 修改请通过对话', 32, top+height-20, w-64, 1, 14)
|
|
113
|
+
parts.append('</g></svg>')
|
|
114
|
+
return ''.join(parts).encode('utf-8')
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def files(artifact, item=None):
|
|
118
|
+
data = artifact['data']
|
|
119
|
+
# The exact template contract travels with the prototype; callers supply the confirmed item.
|
|
120
|
+
item = item or artifact.get('renderItem')
|
|
121
|
+
core.require(item, 'WIDGET_RENDER_ITEM', '生成原型需要对应清单项。')
|
|
122
|
+
value = {**data, 'name': item['name'], 'interactions': item['readInteractions']}
|
|
123
|
+
raw_json = json.dumps(value, ensure_ascii=False).replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026')
|
|
124
|
+
page = '''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>小挂件原型</title><link rel="stylesheet" href="style.css"></head><body><header><span>‹</span><span class="screen-title"></span><span>···</span></header><main></main><div class="composer">在对话中告诉搭子你的修改</div><div class="overlay" hidden><section class="sheet" role="dialog" aria-modal="true" aria-label="小挂件展开页"><div class="sheet-head"><button class="close sheet-close" aria-label="关闭小挂件">×</button><h2></h2><span class="spacer"></span></div><div class="scroll"></div></section><div class="detail" hidden><section class="detail-box" role="dialog" aria-modal="true" aria-label="只读详情"><button class="close detail-close" aria-label="关闭详情">×</button><p class="detail-text"></p></section></div></div><script type="application/json" id="prototype-data">''' + raw_json + '''</script><script src="app.js"></script></body></html>'''
|
|
125
|
+
result = {'index.html': page.encode('utf-8'), 'style.css': CSS.encode('utf-8'), 'app.js': JS.encode('utf-8')}
|
|
126
|
+
for sample in data['states']:
|
|
127
|
+
for view in ('chat', 'expanded'):
|
|
128
|
+
result[view+'-'+sample['id']+'.svg'] = _svg(value, sample, view == 'expanded')
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def directory(workspace, artifact):
|
|
133
|
+
root = Path(workspace) / 'widget-assets'
|
|
134
|
+
path = root / artifact['hash']
|
|
135
|
+
core.require(re_full_hash(artifact['hash']) and not root.is_symlink() and not path.is_symlink(), 'WIDGET_ASSET_PATH', '小挂件资源目录无效。')
|
|
136
|
+
return path
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def re_full_hash(value):
|
|
140
|
+
return isinstance(value, str) and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def render(workspace, state, artifact):
|
|
144
|
+
import widgets
|
|
145
|
+
item = next(i for i in widgets.items(state) if i['id'] == artifact['data']['widgetId'])
|
|
146
|
+
output = directory(workspace, artifact)
|
|
147
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
from completion import _atomic
|
|
149
|
+
for name, raw in files(artifact, item).items():
|
|
150
|
+
core.require(not (output / name).is_symlink(), 'WIDGET_ASSET_PATH', '资源文件不能为符号链接。')
|
|
151
|
+
_atomic(output / name, raw)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def asset_status(workspace, artifact, state=None):
|
|
155
|
+
if not artifact:
|
|
156
|
+
return {'pass': False, 'message': '原型尚未生成'}
|
|
157
|
+
try:
|
|
158
|
+
import widgets
|
|
159
|
+
state = state or core.load(workspace)
|
|
160
|
+
core.require(artifact['data'].get('rendererVersion') == 1, 'WIDGET_RENDERER_VERSION', '原型模板版本已变化,需要重新生成并确认设计。')
|
|
161
|
+
item = next(i for i in widgets.items(state) if i['id'] == artifact['data']['widgetId'])
|
|
162
|
+
core.require(widgets.item_hash(item) == artifact['data']['itemHash'], 'WIDGET_ITEM_STALE', '清单项已更新,需要重绘原型。')
|
|
163
|
+
output = directory(workspace, artifact)
|
|
164
|
+
expected = files(artifact, item)
|
|
165
|
+
for name, raw in expected.items():
|
|
166
|
+
path = output / name
|
|
167
|
+
core.require(not path.is_symlink() and path.read_bytes() == raw, 'WIDGET_ASSET_CHANGED', '原型资源缺失或被修改,请用 widget_render 恢复当前版本。', {'file': name})
|
|
168
|
+
return {'pass': True, 'message': '尺寸、字体、只读控件及两视图三态资源检查通过;内容和阅读体验由开发者单独验收。',
|
|
169
|
+
'files': [{'name': name, 'sha256': hashlib.sha256(raw).hexdigest()} for name, raw in expected.items()]}
|
|
170
|
+
except (core.BuddyError, OSError, StopIteration, KeyError, ValueError) as error:
|
|
171
|
+
return {'pass': False, 'message': str(error) or '当前资源不可用,请重新生成原型。'}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def require_assets(workspace, state, artifact):
|
|
175
|
+
status = asset_status(workspace, artifact, state)
|
|
176
|
+
core.require(status['pass'], 'WIDGET_ASSETS_REQUIRED', status['message'])
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Composed read-only visual templates, sharing their HTML composition with SVG exports."""
|
|
2
|
+
import html
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import buddy_core as core
|
|
6
|
+
|
|
7
|
+
ASSETS = Path(__file__).resolve().parents[1] / 'assets/widget-v2'
|
|
8
|
+
ICONS = {
|
|
9
|
+
'route': '<circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M9 6h7a4 4 0 0 1 0 8H8a4 4 0 0 0 0 8"/>',
|
|
10
|
+
'ticket': '<path d="M3 5h18v5a2 2 0 0 0 0 4v5H3v-5a2 2 0 0 0 0-4Z"/><path d="M15 5v3m0 3v2m0 3v3"/>',
|
|
11
|
+
'wallet': '<path d="M20 8V5H6a3 3 0 0 0 0 6h15v9H5a3 3 0 0 1-3-3V8"/><path d="M21 12h-6v5h6"/><circle cx="17" cy="14.5" r=".5"/>',
|
|
12
|
+
'bag': '<rect x="4" y="7" width="16" height="14" rx="4"/><path d="M8 7V5a4 4 0 0 1 8 0v2M8 13h8m-8 4h5"/>',
|
|
13
|
+
'activity': '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
|
|
14
|
+
'document': '<path d="M5 3h10l4 4v14H5Z"/><path d="M14 3v5h5M8 12h8m-8 4h5"/>',
|
|
15
|
+
'bed': '<path d="M3 19V8m18 11V8M3 15h18M5 15V6h14v9M7 10h4v5m2-5h4v5"/>',
|
|
16
|
+
'train': '<rect x="5" y="2" width="14" height="17" rx="5"/><path d="M5 10h14M8 19l-2 3m10-3 2 3M12 3v7"/><circle cx="9" cy="15" r="1"/><circle cx="15" cy="15" r="1"/>',
|
|
17
|
+
'coffee': '<path d="M3 9h13v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Zm13 1h2a3 3 0 0 1 0 6h-2M7 3v3m5-3v3"/>',
|
|
18
|
+
'pin': '<path d="M19 10c0 5-7 11-7 11S5 15 5 10a7 7 0 0 1 14 0Z"/><circle cx="12" cy="10" r="2"/>',
|
|
19
|
+
'check': '<path d="m5 12 4 4L19 6"/>',
|
|
20
|
+
'clock': '<circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/>',
|
|
21
|
+
'info': '<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-11v1"/>',
|
|
22
|
+
'arrow': '<path d="M4 12h15m-6-6 6 6-6 6"/>',
|
|
23
|
+
'chevron': '<path d="m9 5 7 7-7 7"/>',
|
|
24
|
+
'close': '<path d="m6 6 12 12M6 18 18 6"/>',
|
|
25
|
+
'signal': '<path d="M4 19v-4m5 4v-8m5 8V7m5 12V3"/>',
|
|
26
|
+
'battery': '<rect x="2" y="6" width="17" height="12" rx="3"/><path d="M22 10v4M5 9h11v6H5Z"/>',
|
|
27
|
+
'plus': '<path d="M12 4v16M4 12h16"/>',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def esc(value):
|
|
31
|
+
return html.escape(str(value), quote=True)
|
|
32
|
+
|
|
33
|
+
def icon(name):
|
|
34
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" aria-hidden="true">' + ICONS.get(name, ICONS['document']) + '</svg>'
|
|
35
|
+
|
|
36
|
+
def metrics(values, class_name):
|
|
37
|
+
cells = ''.join('<div class="metric"><span class="metric-label">'+esc(m['label'])+'</span><span class="metric-value">'+esc(m['value'])+'</span></div>' for m in values)
|
|
38
|
+
return '<div class="'+class_name+'" style="--metric-count:'+str(max(1,len(values)))+'">'+cells+'</div>' if values else ''
|
|
39
|
+
|
|
40
|
+
def overview(sample):
|
|
41
|
+
value = sample['overview']
|
|
42
|
+
result = '<div class="overview"><p class="eyebrow">'+esc(value['eyebrow'])+'</p><h3 class="overview-title">'+esc(value['title'])+'</h3><p class="overview-subtitle">'+esc(value['subtitle'])+'</p>'
|
|
43
|
+
result += metrics(value['metrics'], 'overview-metrics')
|
|
44
|
+
if value.get('progress'):
|
|
45
|
+
p=value['progress']; percentage=p['value']/p['max']*100
|
|
46
|
+
result += '<div class="progress-track" role="img" aria-label="'+esc(p['label'])+' '+str(round(percentage,1))+'%"><div class="progress-fill" style="width:'+str(percentage)+'%"></div></div><p class="progress-caption">'+esc(p['label'])+'</p>'
|
|
47
|
+
return result+'</div>'
|
|
48
|
+
|
|
49
|
+
def row_html(row, default_icon):
|
|
50
|
+
status=row.get('status'); tone=status['tone'] if status else ''
|
|
51
|
+
result='<div class="row '+tone+'"><span class="row-symbol">'+icon(row.get('icon',default_icon))+'</span><div class="row-content"><div class="row-label">'+esc(row['label'])+'</div><p class="row-value">'+esc(row['value'])+'</p>'
|
|
52
|
+
if row.get('caption'): result+='<p class="row-caption">'+esc(row['caption'])+'</p>'
|
|
53
|
+
if status: result+='<span class="status '+tone+'">'+esc(status['label'])+'</span>'
|
|
54
|
+
result+='</div>'
|
|
55
|
+
if row.get('detail'):
|
|
56
|
+
result+='<button class="detail-open" aria-label="查看说明:'+esc(row['label'])+'">'+icon('chevron')+'</button><span class="detail-copy" hidden="hidden">'+esc(row['detail'])+'</span>'
|
|
57
|
+
return result+'</div>'
|
|
58
|
+
|
|
59
|
+
def screen(data,item,sample,expanded=False):
|
|
60
|
+
design=data['design']; interactions=item['readInteractions']; ov=sample['overview']
|
|
61
|
+
selected=sample['sections'][0]['id'] if 'tabs' in interactions else 'all'
|
|
62
|
+
result='<div class="screen '+design['layout']+'" data-state="'+sample['id']+'" style="--accent:'+design['accent']+';--title-size:'+str(design['titleSize'])+'px">'
|
|
63
|
+
result+='<div class="statusbar"><span>9:41</span><span class="device">'+icon('signal')+icon('battery')+'</span></div>'
|
|
64
|
+
result+='<header class="chat-header"><span class="back">‹</span><span class="header-mark">'+esc(design['conversationTitle'][:1])+'</span><div class="buddy-identity"><strong>'+esc(design['conversationTitle'])+'</strong><small>随对话整理,随时回来查看</small></div><span>···</span></header>'
|
|
65
|
+
result+='<main class="chat-body"><p class="chat-date">对话示例</p><div class="message user"><div class="bubble">'+esc(sample['userMessage'])+'</div></div><div class="message assistant"><span class="avatar">'+esc(design['conversationTitle'][:1])+'</span><div class="bubble">'+esc(sample['buddyMessage'])+'</div></div>'
|
|
66
|
+
result+='<article class="chat-card"><div class="card-top"><span class="identity-icon">'+icon(design['icon'])+'</span><div><p class="card-kind">'+esc(ov['eyebrow'])+'</p><h1>'+esc(item['name'])+'</h1></div></div><p class="card-summary">'+esc(sample['summary'])+'</p>'
|
|
67
|
+
result+=metrics(ov['metrics'][:2],'card-meta')+'<button class="open-sheet"><span>去查看</span>'+icon('arrow')+'</button><div class="card-foot">'+icon('clock')+'<span>内容随对话更新</span></div></article></main>'
|
|
68
|
+
result+='<div class="composer">'+icon('plus')+'<span>修改请在对话中告诉搭子</span>'+icon('arrow')+'</div><div class="home-indicator"></div>'
|
|
69
|
+
result+='<div class="overlay"'+('' if expanded else ' hidden="hidden"')+'><section class="sheet" role="dialog" aria-modal="true" aria-label="小挂件展开页"><div class="sheet-header"><span class="identity-icon">'+icon(design['icon'])+'</span><h2>'+esc(item['name'])+'</h2><button class="close sheet-close" aria-label="关闭小挂件">'+icon('close')+'</button></div><div class="sheet-body">'
|
|
70
|
+
result+=overview(sample)
|
|
71
|
+
if 'tabs' in interactions or 'filter' in interactions:
|
|
72
|
+
options=sample['sections'] if 'tabs' in interactions else [{'id':'all','title':'全部'}]+sample['sections']
|
|
73
|
+
result+='<nav class="reading-nav" aria-label="筛选已有内容">'
|
|
74
|
+
for group in options:
|
|
75
|
+
result+='<button data-group="'+esc(group['id'])+'" aria-pressed="'+str(group['id']==selected).lower()+'">'+esc(group['title'])+'</button>'
|
|
76
|
+
result+='</nav>'
|
|
77
|
+
for group in sample['sections']:
|
|
78
|
+
collapsed='collapse' in interactions;tag='details' if collapsed else 'section';heading='summary' if collapsed else 'h3'
|
|
79
|
+
result+='<'+tag+' class="section" data-section="'+esc(group['id'])+'"'+(' open="open"' if collapsed else '')+(' hidden="hidden"' if selected!='all' and selected!=group['id'] else '')+'>'
|
|
80
|
+
result+='<'+heading+' class="section-heading">'+esc(group['title'])+'<span class="section-count">'+str(len(group['rows']))+'</span>'+(icon('chevron') if collapsed else '')+'</'+heading+'>'
|
|
81
|
+
result+=''.join(row_html(row,design['icon']) for row in group['rows'])+'</'+tag+'>'
|
|
82
|
+
result+='<p class="note">'+icon('info')+'<span>如需修改内容,请回到对话中告诉搭子。</span></p></div></section><div class="detail-layer" hidden="hidden"><section class="detail-box" role="dialog" aria-modal="true" aria-label="只读详情"><div class="detail-head"><h3 class="detail-title">详情</h3><button class="close detail-close" aria-label="关闭详情">'+icon('close')+'</button></div><p class="detail-text"></p><p class="detail-foot">只读详情 · 修改通过对话完成</p></section></div></div></div>'
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
def files(artifact,item=None):
|
|
86
|
+
item=item or artifact.get('renderItem')
|
|
87
|
+
core.require(item,'WIDGET_RENDER_ITEM','生成原型需要对应清单项。')
|
|
88
|
+
data=artifact['data'];css=(ASSETS/'style.css').read_bytes();js=(ASSETS/'app.js').read_bytes()
|
|
89
|
+
screens=''.join(screen(data,item,sample) for sample in data['states'])
|
|
90
|
+
page='<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>'+esc(item['name'])+' · 小挂件原型</title><link rel="stylesheet" href="style.css"/></head><body>'+screens+'<script src="app.js"></script></body></html>'
|
|
91
|
+
result={'index.html':page.encode('utf-8'),'style.css':css,'app.js':js}
|
|
92
|
+
d=data['design'];w,h=d['screenWidth'],d['screenHeight']
|
|
93
|
+
for sample in data['states']:
|
|
94
|
+
for view in ('chat','expanded'):
|
|
95
|
+
content=screen(data,item,sample,view=='expanded')
|
|
96
|
+
svg='<svg xmlns="http://www.w3.org/2000/svg" width="'+str(w)+'" height="'+str(h)+'" viewBox="0 0 '+str(w)+' '+str(h)+'"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" class="svg-export" style="width:'+str(w)+'px;height:'+str(h)+'px"><style>'+html.escape(css.decode())+'</style>'+content+'</div></foreignObject></svg>'
|
|
97
|
+
result[view+'-'+sample['id']+'.svg']=svg.encode('utf-8')
|
|
98
|
+
return result
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Quiet, content-led prototypes. Earlier renderers remain immutable."""
|
|
2
|
+
import html
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import buddy_core as core
|
|
6
|
+
from widget_render_v2 import esc, icon, metrics
|
|
7
|
+
|
|
8
|
+
ASSETS = Path(__file__).resolve().parents[1] / 'assets/widget-v3'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def overview(sample, layout):
|
|
12
|
+
value = sample['overview']
|
|
13
|
+
result = '<div class="overview"><h3>'+esc(value['title'])+'</h3><p class="overview-subtitle">'+esc(value['subtitle'])+'</p>'
|
|
14
|
+
if layout == 'ledger':
|
|
15
|
+
result += metrics(value['metrics'], 'overview-metrics')
|
|
16
|
+
elif layout == 'checklist':
|
|
17
|
+
result += '<p class="reading-meta">' + esc(' · '.join(m['label']+' '+m['value'] for m in value['metrics'])) + '</p>'
|
|
18
|
+
if layout in ('ledger', 'checklist') and value.get('progress'):
|
|
19
|
+
progress = value['progress']
|
|
20
|
+
percentage = progress['value'] / progress['max'] * 100
|
|
21
|
+
result += '<div class="progress-track" role="img" aria-label="'+esc(progress['label'])+' '+str(round(percentage, 1))+'%"><div style="width:'+str(percentage)+'%"></div></div>'
|
|
22
|
+
if layout == 'ledger':
|
|
23
|
+
result += '<p class="progress-caption">'+esc(progress['label'])+'</p>'
|
|
24
|
+
return result+'</div>'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def row_html(row, layout):
|
|
28
|
+
status = row.get('status')
|
|
29
|
+
result = '<div class="row">'
|
|
30
|
+
if layout == 'timeline':
|
|
31
|
+
result += '<div class="time">'+esc(row['label'])+'</div>'
|
|
32
|
+
result += '<div class="row-content">'
|
|
33
|
+
if layout != 'timeline':
|
|
34
|
+
result += '<div class="row-label">'+esc(row['label'])+'</div>'
|
|
35
|
+
result += '<p class="row-value">'+esc(row['value'])+'</p>'
|
|
36
|
+
if row.get('caption'):
|
|
37
|
+
result += '<p class="row-caption">'+esc(row['caption'])+'</p>'
|
|
38
|
+
# A state already written in the sentence does not need a duplicate badge.
|
|
39
|
+
if status and status['label'] not in row['value'] and status['label'] not in row.get('caption', ''):
|
|
40
|
+
result += '<span class="status '+status['tone']+'">'+esc(status['label'])+'</span>'
|
|
41
|
+
result += '</div>'
|
|
42
|
+
if row.get('detail'):
|
|
43
|
+
result += '<button class="detail-open" aria-label="查看说明:'+esc(row['label'])+'">'+icon('chevron')+'</button><span class="detail-copy" hidden="hidden">'+esc(row['detail'])+'</span>'
|
|
44
|
+
return result+'</div>'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def screen(data, item, sample, expanded=False):
|
|
48
|
+
design = data['design']; layout = design['layout']; interactions = item['readInteractions']
|
|
49
|
+
selected = sample['sections'][0]['id'] if 'tabs' in interactions else 'all'
|
|
50
|
+
name = design['conversationTitle']
|
|
51
|
+
result = '<div class="screen '+layout+'" data-state="'+sample['id']+'" style="--accent:'+design['accent']+';--title-size:'+str(design['titleSize'])+'px">'
|
|
52
|
+
result += '<div class="statusbar"><span>9:41</span><span class="device">'+icon('signal')+icon('battery')+'</span></div><header class="chat-header"><span class="back">‹</span><strong>'+esc(name)+'</strong><span>···</span></header>'
|
|
53
|
+
result += '<main class="chat-body"><p class="chat-date">对话示例</p><div class="message user"><div class="bubble">'+esc(sample['userMessage'])+'</div></div><div class="message assistant"><span class="avatar">'+esc(name[:1])+'</span><div class="bubble">'+esc(sample['buddyMessage'])+'</div></div>'
|
|
54
|
+
result += '<article class="chat-card"><div class="card-content"><p class="card-kind">'+esc(item['name'])+'</p><h1>'+esc(sample['overview']['title'])+'</h1><p class="card-summary">'+esc(sample['summary'])+'</p></div><button class="open-sheet"><span>去查看</span>'+icon('arrow')+'</button></article></main>'
|
|
55
|
+
result += '<div class="composer">'+icon('plus')+'<span>修改请在对话中告诉搭子</span>'+icon('arrow')+'</div><div class="home-indicator"></div>'
|
|
56
|
+
result += '<div class="overlay"'+('' if expanded else ' hidden="hidden"')+'><section class="sheet" tabindex="-1" role="dialog" aria-modal="true" aria-label="小挂件展开页"><div class="sheet-header"><h2>'+esc(item['name'])+'</h2><button class="close sheet-close" aria-label="关闭小挂件">'+icon('close')+'</button></div><div class="sheet-body">'+overview(sample, layout)
|
|
57
|
+
if 'tabs' in interactions or 'filter' in interactions:
|
|
58
|
+
options = sample['sections'] if 'tabs' in interactions else [{'id':'all','title':'全部'}]+sample['sections']
|
|
59
|
+
result += '<nav class="reading-nav" aria-label="筛选已有内容">'
|
|
60
|
+
for group in options:
|
|
61
|
+
result += '<button data-group="'+esc(group['id'])+'" aria-pressed="'+str(group['id']==selected).lower()+'">'+esc(group['title'])+'</button>'
|
|
62
|
+
result += '</nav>'
|
|
63
|
+
for group in sample['sections']:
|
|
64
|
+
collapsible = 'collapse' in interactions
|
|
65
|
+
tag = 'details' if collapsible else 'section'; heading = 'summary' if collapsible else 'h3'
|
|
66
|
+
result += '<'+tag+' class="section" data-section="'+esc(group['id'])+'"'+(' open="open"' if collapsible else '')+(' hidden="hidden"' if selected!='all' and selected!=group['id'] else '')+'>'
|
|
67
|
+
result += '<'+heading+' class="section-heading">'+esc(group['title'])+(icon('chevron') if collapsible else '')+'</'+heading+'>'
|
|
68
|
+
result += ''.join(row_html(row, layout) for row in group['rows'])+'</'+tag+'>'
|
|
69
|
+
result += '<p class="note">如需修改,请在对话中告诉搭子。</p></div></section><div class="detail-layer" hidden="hidden"><section class="detail-box" role="dialog" aria-modal="true" aria-label="只读详情"><div class="detail-head"><h3 class="detail-title">详情</h3><button class="close detail-close" aria-label="关闭详情">'+icon('close')+'</button></div><p class="detail-text"></p><p class="detail-foot">修改请通过对话完成</p></section></div></div></div>'
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def files(artifact, item=None):
|
|
74
|
+
item = item or artifact.get('renderItem')
|
|
75
|
+
core.require(item, 'WIDGET_RENDER_ITEM', '生成原型需要对应清单项。')
|
|
76
|
+
data = artifact['data']; css = (ASSETS/'style.css').read_bytes(); js = (ASSETS/'app.js').read_bytes()
|
|
77
|
+
screens = ''.join(screen(data, item, sample) for sample in data['states'])
|
|
78
|
+
page = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>'+esc(item['name'])+' · 小挂件原型</title><link rel="stylesheet" href="style.css"/></head><body>'+screens+'<script src="app.js"></script></body></html>'
|
|
79
|
+
result = {'index.html':page.encode('utf-8'), 'style.css':css, 'app.js':js}
|
|
80
|
+
w, h = data['design']['screenWidth'], data['design']['screenHeight']
|
|
81
|
+
for sample in data['states']:
|
|
82
|
+
for view in ('chat', 'expanded'):
|
|
83
|
+
content = screen(data, item, sample, view=='expanded')
|
|
84
|
+
svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'+str(w)+'" height="'+str(h)+'" viewBox="0 0 '+str(w)+' '+str(h)+'"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" class="svg-export" style="width:'+str(w)+'px;height:'+str(h)+'px"><style>'+html.escape(css.decode())+'</style>'+content+'</div></foreignObject></svg>'
|
|
85
|
+
result[view+'-'+sample['id']+'.svg'] = svg.encode('utf-8')
|
|
86
|
+
return result
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Task-specific layouts and chat previews within one visual family."""
|
|
2
|
+
import html
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import buddy_core as core
|
|
6
|
+
from widget_render_v2 import esc, icon, metrics
|
|
7
|
+
|
|
8
|
+
ASSETS = Path(__file__).resolve().parents[1] / 'assets/widget-v4'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def change_pair(row):
|
|
12
|
+
change = row['change']
|
|
13
|
+
proposed = row['status']['tone'] == 'pending'
|
|
14
|
+
before_label, after_label = ('原安排', '建议调整') if proposed else ('调整前', '调整后')
|
|
15
|
+
return '<div class="change-pair"><div class="before"><small>'+before_label+'</small><p>'+esc(change['before'])+'</p></div><span class="change-arrow" aria-hidden="true">→</span><div class="after"><small>'+after_label+'</small><p>'+esc(change['after'])+'</p></div></div>'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def card_preview(data, sample):
|
|
19
|
+
layout = data['design']['layout']
|
|
20
|
+
groups = sample['sections']
|
|
21
|
+
if layout == 'timeline':
|
|
22
|
+
return '<div class="calendar-strip">'+''.join('<div><span>'+esc(group['title'])+'</span><small>'+esc(group['rows'][0]['label'])+'</small></div>' for group in groups[:4])+'</div>'
|
|
23
|
+
if layout == 'cards':
|
|
24
|
+
return '<div class="booking-reference"><span>'+esc(groups[0]['title'])+'</span><p>'+esc(groups[0]['rows'][0]['label'])+'</p></div>'
|
|
25
|
+
if layout == 'ledger':
|
|
26
|
+
return metrics(sample['overview']['metrics'][:2], 'receipt-totals')
|
|
27
|
+
if layout == 'checklist':
|
|
28
|
+
return '<div class="packing-index">'+''.join('<div><small>'+esc(group['title'])+'</small><p>'+esc(group['rows'][0]['label'])+'</p></div>' for group in groups[:4])+'</div>'
|
|
29
|
+
if layout == 'changes':
|
|
30
|
+
row = next((row for group in groups for row in group['rows'] if row.get('change')), None)
|
|
31
|
+
if row:
|
|
32
|
+
return '<div class="change-preview"><div class="change-preview-label">'+esc(row['value'])+' · '+esc(row['status']['label'])+'</div>'+change_pair(row)+'</div>'
|
|
33
|
+
return ''
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def chat_card(data, item, sample):
|
|
37
|
+
return '<article class="chat-card"><div class="card-content"><p class="card-kind">'+esc(item['name'])+'</p><h1>'+esc(sample['overview']['title'])+'</h1>'+card_preview(data, sample)+'<p class="card-summary">'+esc(sample['summary'])+'</p></div><button class="open-sheet"><span>去查看</span>'+icon('arrow')+'</button></article>'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def overview(sample, layout):
|
|
41
|
+
value = sample['overview']
|
|
42
|
+
result = '<div class="overview"><h3>'+esc(value['title'])+'</h3><p class="overview-subtitle">'+esc(value['subtitle'])+'</p>'
|
|
43
|
+
if layout == 'ledger':
|
|
44
|
+
result += metrics(value['metrics'], 'overview-metrics')
|
|
45
|
+
elif layout == 'checklist':
|
|
46
|
+
result += '<p class="reading-meta">' + esc(' · '.join(m['label']+' '+m['value'] for m in value['metrics'])) + '</p>'
|
|
47
|
+
if layout in ('ledger', 'checklist') and value.get('progress'):
|
|
48
|
+
progress = value['progress']
|
|
49
|
+
percentage = progress['value'] / progress['max'] * 100
|
|
50
|
+
result += '<div class="progress-track" role="img" aria-label="'+esc(progress['label'])+' '+str(round(percentage, 1))+'%"><div style="width:'+str(percentage)+'%"></div></div>'
|
|
51
|
+
if layout == 'ledger':
|
|
52
|
+
result += '<p class="progress-caption">'+esc(progress['label'])+'</p>'
|
|
53
|
+
return result+'</div>'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def row_html(row, layout):
|
|
57
|
+
status = row.get('status')
|
|
58
|
+
result = '<div class="row">'
|
|
59
|
+
if layout == 'timeline':
|
|
60
|
+
result += '<div class="time">'+esc(row['label'])+'</div>'
|
|
61
|
+
result += '<div class="row-content">'
|
|
62
|
+
if layout == 'changes':
|
|
63
|
+
result += '<div class="change-heading"><div class="row-label">'+esc(row['value'])+'</div><span class="change-time">'+esc(row['label'])+'</span></div>'
|
|
64
|
+
elif layout != 'timeline':
|
|
65
|
+
result += '<div class="row-label">'+esc(row['label'])+'</div>'
|
|
66
|
+
if layout != 'changes':
|
|
67
|
+
result += '<p class="row-value">'+esc(row['value'])+'</p>'
|
|
68
|
+
if row.get('change'):
|
|
69
|
+
result += change_pair(row)
|
|
70
|
+
if row.get('caption'):
|
|
71
|
+
result += '<p class="row-caption">'+esc(row['caption'])+'</p>'
|
|
72
|
+
# A state already written in the sentence does not need a duplicate badge.
|
|
73
|
+
if status and status['label'] not in row['value'] and status['label'] not in row.get('caption', ''):
|
|
74
|
+
result += '<span class="status '+status['tone']+'">'+esc(status['label'])+'</span>'
|
|
75
|
+
result += '</div>'
|
|
76
|
+
if row.get('detail'):
|
|
77
|
+
result += '<button class="detail-open" aria-label="查看说明:'+esc(row['label'])+'">'+icon('chevron')+'</button><span class="detail-copy" hidden="hidden">'+esc(row['detail'])+'</span>'
|
|
78
|
+
return result+'</div>'
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def screen(data, item, sample, expanded=False):
|
|
82
|
+
design = data['design']; layout = design['layout']; interactions = item['readInteractions']
|
|
83
|
+
selected = sample['sections'][0]['id'] if 'tabs' in interactions else 'all'
|
|
84
|
+
name = design['conversationTitle']
|
|
85
|
+
result = '<div class="screen '+layout+'" data-state="'+sample['id']+'" style="--accent:'+design['accent']+';--title-size:'+str(design['titleSize'])+'px">'
|
|
86
|
+
result += '<div class="statusbar"><span>9:41</span><span class="device">'+icon('signal')+icon('battery')+'</span></div><header class="chat-header"><span class="back">‹</span><strong>'+esc(name)+'</strong><span>···</span></header>'
|
|
87
|
+
result += '<main class="chat-body"><p class="chat-date">对话示例</p><div class="message user"><div class="bubble">'+esc(sample['userMessage'])+'</div></div><div class="message assistant"><span class="avatar">'+esc(name[:1])+'</span><div class="bubble">'+esc(sample['buddyMessage'])+'</div></div>'
|
|
88
|
+
result += chat_card(data, item, sample)+'</main>'
|
|
89
|
+
result += '<div class="composer">'+icon('plus')+'<span>修改请在对话中告诉搭子</span>'+icon('arrow')+'</div><div class="home-indicator"></div>'
|
|
90
|
+
result += '<div class="overlay"'+('' if expanded else ' hidden="hidden"')+'><section class="sheet" tabindex="-1" role="dialog" aria-modal="true" aria-label="小挂件展开页"><div class="sheet-header"><h2>'+esc(item['name'])+'</h2><button class="close sheet-close" aria-label="关闭小挂件">'+icon('close')+'</button></div><div class="sheet-body">'+overview(sample, layout)
|
|
91
|
+
if 'tabs' in interactions or 'filter' in interactions:
|
|
92
|
+
options = sample['sections'] if 'tabs' in interactions else [{'id':'all','title':'全部'}]+sample['sections']
|
|
93
|
+
result += '<nav class="reading-nav" aria-label="筛选已有内容">'
|
|
94
|
+
for index, group in enumerate(options):
|
|
95
|
+
label = '<span class="day-number">'+str(index+1).zfill(2)+'</span><span>'+esc(group['title'])+'</span>' if layout == 'timeline' and 'tabs' in interactions else esc(group['title'])
|
|
96
|
+
result += '<button data-group="'+esc(group['id'])+'" aria-pressed="'+str(group['id']==selected).lower()+'">'+label+'</button>'
|
|
97
|
+
result += '</nav>'
|
|
98
|
+
for group in sample['sections']:
|
|
99
|
+
collapsible = 'collapse' in interactions
|
|
100
|
+
tag = 'details' if collapsible else 'section'; heading = 'summary' if collapsible else 'h3'
|
|
101
|
+
result += '<'+tag+' class="section" data-section="'+esc(group['id'])+'"'+(' open="open"' if collapsible else '')+(' hidden="hidden"' if selected!='all' and selected!=group['id'] else '')+'>'
|
|
102
|
+
result += '<'+heading+' class="section-heading">'+esc(group['title'])+(icon('chevron') if collapsible else '')+'</'+heading+'>'
|
|
103
|
+
result += '<div class="section-rows">'+''.join(row_html(row, layout) for row in group['rows'])+'</div></'+tag+'>'
|
|
104
|
+
result += '<p class="note">如需修改,请在对话中告诉搭子。</p></div></section><div class="detail-layer" hidden="hidden"><section class="detail-box" role="dialog" aria-modal="true" aria-label="只读详情"><div class="detail-head"><h3 class="detail-title">详情</h3><button class="close detail-close" aria-label="关闭详情">'+icon('close')+'</button></div><p class="detail-text"></p><p class="detail-foot">修改请通过对话完成</p></section></div></div></div>'
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def files(artifact, item=None):
|
|
109
|
+
item = item or artifact.get('renderItem')
|
|
110
|
+
core.require(item, 'WIDGET_RENDER_ITEM', '生成原型需要对应清单项。')
|
|
111
|
+
data = artifact['data']; css = (ASSETS/'style.css').read_bytes(); js = (ASSETS/'app.js').read_bytes()
|
|
112
|
+
screens = ''.join(screen(data, item, sample) for sample in data['states'])
|
|
113
|
+
page = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>'+esc(item['name'])+' · 小挂件原型</title><link rel="stylesheet" href="style.css"/></head><body>'+screens+'<script src="app.js"></script></body></html>'
|
|
114
|
+
result = {'index.html':page.encode('utf-8'), 'style.css':css, 'app.js':js}
|
|
115
|
+
w, h = data['design']['screenWidth'], data['design']['screenHeight']
|
|
116
|
+
for sample in data['states']:
|
|
117
|
+
for view in ('chat', 'expanded'):
|
|
118
|
+
content = screen(data, item, sample, view=='expanded')
|
|
119
|
+
svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'+str(w)+'" height="'+str(h)+'" viewBox="0 0 '+str(w)+' '+str(h)+'"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" class="svg-export" style="width:'+str(w)+'px;height:'+str(h)+'px"><style>'+html.escape(css.decode())+'</style>'+content+'</div></foreignObject></svg>'
|
|
120
|
+
result[view+'-'+sample['id']+'.svg'] = svg.encode('utf-8')
|
|
121
|
+
return result
|