dsh-design-studio 0.5.0 → 0.6.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.en.md +8 -2
- package/README.md +15 -2
- package/cordis.patch.yml +1 -1
- package/lib/browser.js +27 -5
- package/lib/client.js +380 -6
- package/lib/core.js +35 -9
- package/lib/handoff.js +555 -0
- package/lib/history.js +1 -1
- package/lib/index.js +258 -6
- package/lib/look.js +1 -1
- package/lib/picker.js +35 -0
- package/lib/serve.js +2 -1
- package/lib/system.js +1360 -0
- package/lib/tool.js +6 -3
- package/lib/tweaks.js +362 -0
- package/locale/tool/en.json +2 -2
- package/locale/tool/zh.json +2 -2
- package/package.json +1 -1
- package/skills/ui-design/SKILL.md +27 -1
package/lib/tool.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-design-studio/tool:把 preview_page、check_design、look_design
|
|
3
|
-
* 放在 bundle 顶层 → 所有模式都能用;放进设计模式 → 只挂在那个模式里。工具本身见 index.js
|
|
2
|
+
* dsh-design-studio/tool:把 preview_page、check_design、look_design、extract_design_system、handoff_design、compare_design 挂给模型。
|
|
3
|
+
* 放在 bundle 顶层 → 所有模式都能用;放进设计模式 → 只挂在那个模式里。工具本身见 index.js
|
|
4
4
|
*/
|
|
5
|
-
import { previewTool, checkTool, lookTool } from './index.js'
|
|
5
|
+
import { previewTool, checkTool, lookTool, systemTool, handoffTool, compareTool } from './index.js'
|
|
6
6
|
|
|
7
7
|
export const name = 'dsh-design-studio-tool'
|
|
8
8
|
export const inject = ['tools']
|
|
@@ -12,4 +12,7 @@ export function apply(ctx) {
|
|
|
12
12
|
// 截的图要存进 DSH 的附件库(ctx.get('attachments')),把这一行的 ctx 带进去
|
|
13
13
|
ctx.tools.register({ ...checkTool, execute: (args, exec) => checkTool.execute(args, exec, ctx) })
|
|
14
14
|
ctx.tools.register({ ...lookTool, execute: (args, exec) => lookTool.execute(args, exec, ctx) })
|
|
15
|
+
ctx.tools.register({ ...systemTool, execute: (args, exec) => systemTool.execute(args, exec, ctx) })
|
|
16
|
+
ctx.tools.register({ ...handoffTool, execute: (args, exec) => handoffTool.execute(args, exec, ctx) })
|
|
17
|
+
ctx.tools.register({ ...compareTool, execute: (args, exec) => compareTool.execute(args, exec, ctx) })
|
|
15
18
|
}
|
package/lib/tweaks.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 专属调节滑块:模型按这套设计挑几个最值得试的维度(留白、圆角、主色、卡片排法……),写进设计目录的 tweaks.json。
|
|
3
|
+
* 右侧栏把它们列成滑块 / 取色 / 几选一,拖的时候所有屏一起变(只在页面上改 CSS 变量和 <html> 上的属性,不动文件);
|
|
4
|
+
* 定下来再写回文件:CSS 变量改样式里 :root 上的默认值(tokens.css 优先),属性写到每屏的 <html> 上,tweaks.json 的 value 跟着改。
|
|
5
|
+
*
|
|
6
|
+
* tweaks.json(也可以直接是数组):
|
|
7
|
+
* { "tweaks": [
|
|
8
|
+
* { "id": "density", "label": "留白", "var": "--density", "min": 0.8, "max": 1.4, "step": 0.05, "value": 1 },
|
|
9
|
+
* { "id": "radius", "label": "圆角", "var": "--radius-md", "min": 0, "max": 28, "unit": "px", "value": 12 },
|
|
10
|
+
* { "id": "brand", "label": "主色", "var": "--color-primary", "type": "color", "value": "#c26a32" },
|
|
11
|
+
* { "id": "layout", "label": "商品排法", "attr": "data-layout", "options": [{ "label": "两列", "value": "grid" }, { "label": "大图", "value": "list" }], "value": "grid" }
|
|
12
|
+
* ] }
|
|
13
|
+
* var + min / max → 滑块;var + type: color → 取色;options(var 或 attr)→ 几选一。attr 只认 data-*。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { listScreens } from './core.js'
|
|
19
|
+
import { parseColor, hexOf } from './system.js'
|
|
20
|
+
|
|
21
|
+
export const TWEAKS_FILE = 'tweaks.json'
|
|
22
|
+
export const MAX_TWEAKS = 8
|
|
23
|
+
const VAR_NAME = /^--[A-Za-z0-9_-]{1,60}$/
|
|
24
|
+
const ATTR_NAME = /^data-[a-z0-9-]{1,40}$/
|
|
25
|
+
const MAX_FILE = 512 * 1024
|
|
26
|
+
|
|
27
|
+
/** 小数位数(1e-7 这种写法也认) */
|
|
28
|
+
const decimals = (n) => {
|
|
29
|
+
const s = String(n)
|
|
30
|
+
const e = /e-(\d+)$/.exec(s)
|
|
31
|
+
return e ? Number(e[1]) : (s.split('.')[1] || '').length
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 夹进 [min, max],再从 min 起按步长取整(和 <input type=range> 一样;最后一格不够一步就退一格) */
|
|
35
|
+
const quantize = (v, min, max, step) => {
|
|
36
|
+
const x = Math.max(min, Math.min(max, v))
|
|
37
|
+
let q = min + Math.round((x - min) / step) * step
|
|
38
|
+
if (q > max + step * 1e-9) q -= step
|
|
39
|
+
return Number(Math.max(min, q).toFixed(Math.min(6, Math.max(decimals(step), decimals(min)))))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 括号、方括号成对(不成对的值写进去会把后面的样式吞掉) */
|
|
43
|
+
const balanced = (s) => {
|
|
44
|
+
const st = []
|
|
45
|
+
for (const ch of s) {
|
|
46
|
+
if (ch === '(') st.push(')')
|
|
47
|
+
else if (ch === '[') st.push(']')
|
|
48
|
+
else if ((ch === ')' || ch === ']') && st.pop() !== ch) return false
|
|
49
|
+
}
|
|
50
|
+
return !st.length
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 滑块的数:±1000000 以内、最多 6 位小数(再细写回时也分不出来) */
|
|
54
|
+
const fine = (n) => Number.isFinite(n) && Math.abs(n) <= 1e6 && decimals(n) <= 6
|
|
55
|
+
|
|
56
|
+
/** 几选一的选项:[{ label, value }] / ["a", "b"] / { "两列": "grid" } → [{ label, value }] */
|
|
57
|
+
function optionsOf(o) {
|
|
58
|
+
const list = Array.isArray(o) ? o.map((x) => (x && typeof x === 'object' ? { label: x.label, value: x.value } : { label: x, value: x }))
|
|
59
|
+
: o && typeof o === 'object' ? Object.entries(o).map(([label, value]) => ({ label, value })) : []
|
|
60
|
+
return list
|
|
61
|
+
// 值会原样写进 CSS / HTML:引号、分号、花括号、注释、反斜杠、!、$ 都不收,圆括号方括号要成对
|
|
62
|
+
.filter((x) => x.value != null && String(x.value).length <= 60 && /^[^<>"'`;{}\\*!$&]*$/.test(String(x.value)) && balanced(String(x.value)) && String(x.value).trim() !== '')
|
|
63
|
+
.map((x) => ({ label: String(x.label == null ? x.value : x.label).slice(0, 12), value: String(x.value) }))
|
|
64
|
+
.slice(0, 6)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* tweaks.json 的内容 → { list, errors }。
|
|
69
|
+
* list:[{ id, i, label, note, kind: 'range' | 'color' | 'choice', var?, attr?, min, max, step, unit, options, value }](i 是在原数组里第几个,写回时用)
|
|
70
|
+
*/
|
|
71
|
+
export function parseTweaks(text) {
|
|
72
|
+
let raw
|
|
73
|
+
try { raw = JSON.parse(String(text || '')) } catch (e) { return { list: [], errors: [`tweaks.json 不是合法的 JSON:${String(e.message).slice(0, 80)}`] } }
|
|
74
|
+
const arr = Array.isArray(raw) ? raw : raw && Array.isArray(raw.tweaks) ? raw.tweaks : null
|
|
75
|
+
if (!arr) return { list: [], errors: ['tweaks.json 里要有 "tweaks": [ … ]'] }
|
|
76
|
+
const list = []
|
|
77
|
+
const errors = []
|
|
78
|
+
const ids = new Set()
|
|
79
|
+
let over = false
|
|
80
|
+
arr.forEach((x, i) => {
|
|
81
|
+
if (list.length >= MAX_TWEAKS) { if (!over) errors.push(`最多 ${MAX_TWEAKS} 个滑块,后面的没显示`); over = true; return }
|
|
82
|
+
if (!x || typeof x !== 'object') { errors.push(`第 ${i + 1} 个不是对象`); return }
|
|
83
|
+
const v = typeof x.var === 'string' && VAR_NAME.test(x.var) ? x.var : ''
|
|
84
|
+
const a = typeof x.attr === 'string' && ATTR_NAME.test(x.attr.toLowerCase()) ? x.attr.toLowerCase() : ''
|
|
85
|
+
const label = String(x.label || x.name || x.id || v || a || '').slice(0, 16)
|
|
86
|
+
if (!v && !a) { errors.push(`「${label || i + 1}」要写 var(CSS 变量,如 --density)或 attr(data- 开头的属性,如 data-layout)`); return }
|
|
87
|
+
let id = typeof x.id === 'string' && /^[\w-]{1,32}$/.test(x.id) ? x.id : (v || a).replace(/^--|^data-/, '')
|
|
88
|
+
while (ids.has(id)) id += '-2'
|
|
89
|
+
ids.add(id)
|
|
90
|
+
const k = { id, i, label, note: typeof x.note === 'string' ? x.note.slice(0, 40) : '' }
|
|
91
|
+
if (v) k.var = v
|
|
92
|
+
else k.attr = a
|
|
93
|
+
const opts = optionsOf(x.options)
|
|
94
|
+
if (opts.length >= 2) {
|
|
95
|
+
k.kind = 'choice'
|
|
96
|
+
k.options = opts
|
|
97
|
+
const cur = x.value == null ? opts[0].value : String(x.value)
|
|
98
|
+
k.value = opts.some((o) => o.value === cur) ? cur : opts[0].value
|
|
99
|
+
} else if (a) {
|
|
100
|
+
errors.push(`「${label}」用 attr 时要给 options(至少两个选项)`)
|
|
101
|
+
return
|
|
102
|
+
} else if (x.type === 'color') {
|
|
103
|
+
const c = parseColor(x.value)
|
|
104
|
+
k.kind = 'color'
|
|
105
|
+
k.value = c ? hexOf(c) : '#000000'
|
|
106
|
+
} else {
|
|
107
|
+
const min = Number(x.min), max = Number(x.max)
|
|
108
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) { errors.push(`「${label}」是滑块,要给 min、max(min 比 max 小)`); return }
|
|
109
|
+
if (!fine(min) || !fine(max)) { errors.push(`「${label}」的 min、max 要在 ±1000000 以内,最多 6 位小数`); return }
|
|
110
|
+
// 没写步长:范围大(像素、度数)一格 1,小的(倍数)分 100 格
|
|
111
|
+
// 步长:要是有限的正数,而且一共不超过 1000 格(太细的按 100 格算)
|
|
112
|
+
const given = Number(x.step)
|
|
113
|
+
const step = fine(given) && given > 0 && (max - min) / given <= 1000 ? given
|
|
114
|
+
: max - min >= 20 ? 1 : Number(((max - min) / 100).toPrecision(1))
|
|
115
|
+
if (!(step > 0) || !fine(step)) { errors.push(`「${label}」的范围太小,分不出格`); return }
|
|
116
|
+
k.kind = 'range'
|
|
117
|
+
k.min = min
|
|
118
|
+
k.max = max
|
|
119
|
+
k.step = step
|
|
120
|
+
k.unit = typeof x.unit === 'string' && /^[a-z%]{0,4}$/i.test(x.unit) ? x.unit : ''
|
|
121
|
+
const val = Number.isFinite(Number(x.value)) ? Number(x.value) : min
|
|
122
|
+
k.value = quantize(val, min, max, step)
|
|
123
|
+
}
|
|
124
|
+
list.push(k)
|
|
125
|
+
})
|
|
126
|
+
return { list, errors }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 一个滑块的值规整一下(越界、不在选项里的给 null) */
|
|
130
|
+
export function normValue(k, v) {
|
|
131
|
+
if (k.kind === 'range') {
|
|
132
|
+
const n = Number(v)
|
|
133
|
+
return Number.isFinite(n) ? quantize(n, k.min, k.max, k.step) : null
|
|
134
|
+
}
|
|
135
|
+
if (k.kind === 'color') { const c = parseColor(v); return c ? hexOf(c) : null }
|
|
136
|
+
return k.options.some((o) => o.value === String(v)) ? String(v) : null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 写进 CSS 的值:滑块带单位 */
|
|
140
|
+
export const cssValue = (k, v) => (k.kind === 'range' ? `${v}${k.unit}` : String(v))
|
|
141
|
+
|
|
142
|
+
/** 给人看的值:滑块带单位,几选一写选项名 */
|
|
143
|
+
export const shownValue = (k, v) => (k.kind === 'choice' ? (k.options.find((o) => o.value === String(v)) || { label: v }).label : cssValue(k, v))
|
|
144
|
+
|
|
145
|
+
const cache = new Map() // 文件 → { mtime, size, r }
|
|
146
|
+
|
|
147
|
+
/** 设计目录里的 tweaks.json → { list, errors };没有这个文件给 null(按修改时间缓存) */
|
|
148
|
+
export function readTweaks(root) {
|
|
149
|
+
if (!root) return null
|
|
150
|
+
const file = path.join(root, TWEAKS_FILE)
|
|
151
|
+
let st
|
|
152
|
+
try { st = fs.lstatSync(file) } catch { cache.delete(file); return null }
|
|
153
|
+
// 只认设计目录里的普通文件:软链接(可能指到目录外)当没有
|
|
154
|
+
if (!st.isFile()) return null
|
|
155
|
+
const hit = cache.get(file)
|
|
156
|
+
if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) return hit.r
|
|
157
|
+
const r = st.size > MAX_FILE ? { list: [], errors: ['tweaks.json 太大了'] } : parseTweaks(fs.readFileSync(file, 'utf8'))
|
|
158
|
+
cache.set(file, { mtime: st.mtimeMs, size: st.size, r })
|
|
159
|
+
if (cache.size > 50) cache.delete(cache.keys().next().value)
|
|
160
|
+
return r
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 写回 ──────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
166
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ')
|
|
167
|
+
/**
|
|
168
|
+
* 注释换成等长的空格、字符串和 url(…) 换成等长的 _(下标不变,换行留着):找声明时不会找到这些里面去,
|
|
169
|
+
* 里面的分号、括号也不会把声明切断
|
|
170
|
+
*/
|
|
171
|
+
const fill = (m) => m.replace(/[^\n]/g, '_')
|
|
172
|
+
// 一遍从左往右认:先碰到哪个算哪个(字符串里的 /* 不是注释,注释里的引号也不是字符串);
|
|
173
|
+
// 字符串、url(…) 换成非空白:它们是值的一部分,不能被当成空白吃掉
|
|
174
|
+
const mask = (css) => css.replace(/\/\*[\s\S]*?\*\/|"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'|\burl\(\s*[^)'"\s][^)]*\)/gi, (m) => (m.startsWith('/*') ? blank(m) : fill(m)))
|
|
175
|
+
/** HTML 里的注释、<script> / <style> 的内容换成等长空格:找 <html> 标签时不会找到这些里面去 */
|
|
176
|
+
const maskHtml = (html, keepStyle = false) => html.replace(/<!--[\s\S]*?-->/g, blank)
|
|
177
|
+
.replace(keepStyle ? /<(script)\b[^>]*>[\s\S]*?<\/script\s*>/gi : /<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, blank)
|
|
178
|
+
|
|
179
|
+
/** 下标 i 在哪个规则块里:{ selector, depth }(depth 1 = 顶层规则,@media 里面的是 2) */
|
|
180
|
+
function blockAt(css, i) {
|
|
181
|
+
const stack = []
|
|
182
|
+
let start = 0
|
|
183
|
+
for (let j = 0; j < i; j++) {
|
|
184
|
+
const ch = css[j]
|
|
185
|
+
if (ch === '{') { stack.push(css.slice(start, j).trim()); start = j + 1 } else if (ch === '}') { stack.pop(); start = j + 1 } else if (ch === ';') start = j + 1
|
|
186
|
+
}
|
|
187
|
+
return stack.length ? { selector: stack[stack.length - 1], depth: stack.length } : null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const ROOTISH = /(^|,)\s*(:root|html)\s*(,|$)/i
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 样式文本里 :root / html 上的 name 声明换成 value(只换值,!important、注释、别的声明都不动)。没找到给 null
|
|
194
|
+
* nested 为假:顶层的全改(同一个变量写了几遍时生效的是最后一处,只改第一处等于没改);
|
|
195
|
+
* nested 为真:只改 @media 等里面的第一处(整个设计目录都没有顶层声明时才用)
|
|
196
|
+
*/
|
|
197
|
+
export function setVarDecl(css, name, value, nested = false) {
|
|
198
|
+
const m0 = mask(css)
|
|
199
|
+
// 只认一条声明的开头(前面是 { ; } 加空白):--other--r、别的变量值里的「--r:」都不算;值到 ; } ! 为止
|
|
200
|
+
const re = new RegExp(`(?<=[{};]\\s*)(${escRe(name)}\\s*:\\s*)([^;{}!]*?)(\\s*(?:;|}|!))`, 'g')
|
|
201
|
+
const hits = []
|
|
202
|
+
for (const m of m0.matchAll(re)) {
|
|
203
|
+
const b = blockAt(m0, m.index)
|
|
204
|
+
if (!b || !ROOTISH.test(b.selector) || (b.depth === 1) === nested) continue
|
|
205
|
+
hits.push(m)
|
|
206
|
+
if (nested) break
|
|
207
|
+
}
|
|
208
|
+
if (!hits.length) return null
|
|
209
|
+
let out = css
|
|
210
|
+
for (const m of hits.reverse()) {
|
|
211
|
+
const at = m.index + m[1].length
|
|
212
|
+
out = out.slice(0, at) + value + out.slice(at + m[2].length)
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** HTML 里 <style> 块中的 :root 声明(规则同 setVarDecl;注释、脚本里的不算) */
|
|
218
|
+
function setVarInHtml(html, name, value, nested = false) {
|
|
219
|
+
const re = /(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi
|
|
220
|
+
const m0 = maskHtml(html, true)
|
|
221
|
+
let out = html
|
|
222
|
+
let hit = false
|
|
223
|
+
for (const m of [...m0.matchAll(re)].reverse()) {
|
|
224
|
+
const at = m.index + m[1].length
|
|
225
|
+
const r = setVarDecl(html.slice(at, at + m[2].length), name, value, nested)
|
|
226
|
+
if (r === null) continue
|
|
227
|
+
out = out.slice(0, at) + r + out.slice(at + m[2].length)
|
|
228
|
+
hit = true
|
|
229
|
+
if (nested) break
|
|
230
|
+
}
|
|
231
|
+
return hit ? out : null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** <html> 开头标签上设属性(有就改,没有就加;注释、脚本里的 <html> 不算) */
|
|
235
|
+
export function setHtmlAttr(html, attr, value) {
|
|
236
|
+
const m = /<html\b(?:"[^"]*"|'[^']*'|[^'">])*>/i.exec(maskHtml(html))
|
|
237
|
+
if (!m) return null
|
|
238
|
+
const tag = html.slice(m.index, m.index + m[0].length)
|
|
239
|
+
const val = String(value).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
240
|
+
const put = ` ${attr}="${val}"`
|
|
241
|
+
// 一个个属性往后读(引号里的不算),找到同名的就换掉它那一段;没有就加在最后
|
|
242
|
+
const ATTR = /\s*([^\s"'>/=]+)(\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?/y
|
|
243
|
+
for (let i = 5; i < tag.length;) {
|
|
244
|
+
ATTR.lastIndex = i
|
|
245
|
+
const a = ATTR.exec(tag)
|
|
246
|
+
if (!a) { i++; continue }
|
|
247
|
+
if (a[1].toLowerCase() === attr.toLowerCase()) {
|
|
248
|
+
const next = tag.slice(0, a.index) + put + tag.slice(a.index + a[0].length)
|
|
249
|
+
return html.slice(0, m.index) + next + html.slice(m.index + tag.length)
|
|
250
|
+
}
|
|
251
|
+
i = a.index + a[0].length
|
|
252
|
+
}
|
|
253
|
+
const end = /\s*\/?>$/.exec(tag)
|
|
254
|
+
const next = tag.slice(0, end.index) + put + tag.slice(end.index).trimStart()
|
|
255
|
+
return html.slice(0, m.index) + next + html.slice(m.index + tag.length)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 设计目录第一层的这个文件是不是自己的:普通文件、不是软链接、真实位置在目录里 */
|
|
259
|
+
function ownFile(root, realRoot, rel) {
|
|
260
|
+
const p = path.join(root, rel)
|
|
261
|
+
try {
|
|
262
|
+
if (!fs.lstatSync(p).isFile()) return false
|
|
263
|
+
return path.dirname(fs.realpathSync(p)) === realRoot
|
|
264
|
+
} catch { return false }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 设计目录里能放 CSS 变量默认值的文件:tokens.css 在前,再是别的 .css,最后是每屏 HTML 里的 <style>(软链接、指到外面的不算) */
|
|
268
|
+
function cssFiles(root, realRoot = '') {
|
|
269
|
+
if (!realRoot) { try { realRoot = fs.realpathSync(root) } catch { return [] } }
|
|
270
|
+
let names = []
|
|
271
|
+
try { names = fs.readdirSync(root) } catch { return [] }
|
|
272
|
+
const css = names.filter((n) => /\.css$/i.test(n) && !n.startsWith('.')).sort((a, b) => (a === 'tokens.css' ? -1 : b === 'tokens.css' ? 1 : a.localeCompare(b)))
|
|
273
|
+
return [...css, ...listScreens(root)].filter((rel) => ownFile(root, realRoot, rel))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 写回:values = { id: 值 }。CSS 变量改样式里的默认值,属性写到每屏的 <html> 上,tweaks.json 的 value 跟着改。
|
|
278
|
+
* @returns { ok, changed: [{ id, label, from, to }], files: [相对路径], missed: [{ id, label }] }(missed:样式里找不到这个变量的默认值)
|
|
279
|
+
*/
|
|
280
|
+
export function applyTweaks(root, values) {
|
|
281
|
+
const r0 = readTweaks(root)
|
|
282
|
+
if (!r0 || !r0.list.length) return { ok: false, reason: 'no-tweaks' }
|
|
283
|
+
let realRoot
|
|
284
|
+
try { realRoot = fs.realpathSync(root) } catch { return { ok: false, reason: 'no-tweaks' } }
|
|
285
|
+
// tweaks.json 是软链接(指到别处)就不写:写回只改设计目录自己的文件
|
|
286
|
+
if (!ownFile(root, realRoot, TWEAKS_FILE)) return { ok: false, reason: 'no-tweaks' }
|
|
287
|
+
const file = path.join(root, TWEAKS_FILE)
|
|
288
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
289
|
+
const arr = Array.isArray(raw) ? raw : raw.tweaks
|
|
290
|
+
const texts = new Map()
|
|
291
|
+
const read = (rel) => (texts.has(rel) ? texts.get(rel) : fs.readFileSync(path.join(root, rel), 'utf8'))
|
|
292
|
+
const changed = []
|
|
293
|
+
const missed = []
|
|
294
|
+
for (const k of r0.list) {
|
|
295
|
+
if (!values || !Object.hasOwn(values, k.id)) continue
|
|
296
|
+
const v = normValue(k, values[k.id])
|
|
297
|
+
if (v == null || v === k.value) continue
|
|
298
|
+
if (k.var) {
|
|
299
|
+
// 先把所有文件里顶层的声明都改掉(哪份样式后加载就是哪份生效,全改才稳);一处都没有再退到 @media 里的第一处
|
|
300
|
+
let done = false
|
|
301
|
+
for (const nested of [false, true]) {
|
|
302
|
+
for (const rel of cssFiles(root, realRoot)) {
|
|
303
|
+
let t
|
|
304
|
+
try { t = read(rel) } catch { continue }
|
|
305
|
+
const next = /\.html?$/i.test(rel) ? setVarInHtml(t, k.var, cssValue(k, v), nested) : setVarDecl(t, k.var, cssValue(k, v), nested)
|
|
306
|
+
if (next === null) continue
|
|
307
|
+
texts.set(rel, next)
|
|
308
|
+
done = true
|
|
309
|
+
if (nested) break
|
|
310
|
+
}
|
|
311
|
+
if (done) break
|
|
312
|
+
}
|
|
313
|
+
if (!done) { missed.push({ id: k.id, label: k.label }); continue }
|
|
314
|
+
} else {
|
|
315
|
+
for (const rel of listScreens(root)) {
|
|
316
|
+
if (!ownFile(root, realRoot, rel)) continue
|
|
317
|
+
const next = setHtmlAttr(read(rel), k.attr, v)
|
|
318
|
+
if (next !== null) texts.set(rel, next)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (arr[k.i] && typeof arr[k.i] === 'object') arr[k.i].value = k.kind === 'range' ? v : String(v)
|
|
322
|
+
changed.push({ id: k.id, label: k.label, from: shownValue(k, k.value), to: shownValue(k, v) })
|
|
323
|
+
}
|
|
324
|
+
if (!changed.length) return { ok: true, changed, files: [], missed }
|
|
325
|
+
texts.set(TWEAKS_FILE, JSON.stringify(raw, null, 2) + '\n')
|
|
326
|
+
for (const [rel, t] of texts) fs.writeFileSync(path.join(root, rel), t)
|
|
327
|
+
return { ok: true, changed, files: [...texts.keys()], missed }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── 体检:滑块有没有真接上 ───────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 只看源码:变量有没有样式在用、有没有默认值;属性有没有样式按它区分。
|
|
334
|
+
* @returns [文字](check_design 的结果里列出来)
|
|
335
|
+
*/
|
|
336
|
+
export function lintTweaks(root) {
|
|
337
|
+
const r = readTweaks(root)
|
|
338
|
+
if (!r) return []
|
|
339
|
+
const out = [...r.errors]
|
|
340
|
+
if (!r.list.length) return out
|
|
341
|
+
let all = ''
|
|
342
|
+
for (const rel of cssFiles(root)) {
|
|
343
|
+
// 只去掉注释:属性选择器里的引号([data-layout="list"])要留着
|
|
344
|
+
try { all += '\n' + fs.readFileSync(path.join(root, rel), 'utf8').slice(0, MAX_FILE).replace(/\/\*[\s\S]*?\*\//g, blank) } catch { /* 读不了 */ }
|
|
345
|
+
}
|
|
346
|
+
for (const k of r.list) {
|
|
347
|
+
if (k.var) {
|
|
348
|
+
if (!new RegExp(`var\\(\\s*${escRe(k.var)}\\b`).test(all)) out.push(`「${k.label}」拖了不会有变化:没有样式用到 var(${k.var})`)
|
|
349
|
+
else if (!new RegExp(`${escRe(k.var)}\\s*:`).test(all)) out.push(`「${k.label}」的 ${k.var} 在样式里没有默认值(写在 tokens.css 的 :root 里,写回文件时才找得到地方写)`)
|
|
350
|
+
if (k.kind === 'choice') {
|
|
351
|
+
const miss = k.options.filter((o) => !all.includes(o.value))
|
|
352
|
+
if (miss.length === k.options.length) out.push(`「${k.label}」的选项值在样式里一个都没出现,${k.var} 换成这些值多半不起作用`)
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
if (!new RegExp(`\\[\\s*${escRe(k.attr)}\\b`, 'i').test(all)) { out.push(`「${k.label}」拖了不会有变化:没有样式按 ${k.attr} 区分(写成 [${k.attr}="${k.options[1].value}"] .xxx { … })`); continue }
|
|
356
|
+
const used = k.options.filter((o) => new RegExp(`\\[\\s*${escRe(k.attr)}\\s*[~|^$*]?=\\s*["']?${escRe(o.value)}["'\\]\\s]`, 'i').test(all))
|
|
357
|
+
const others = k.options.filter((o) => o.value !== k.value)
|
|
358
|
+
if (!others.some((o) => used.includes(o))) out.push(`「${k.label}」除了现在这个,别的选项(${others.map((o) => o.label).join('、')})在样式里都没有对应的 [${k.attr}="…"] 规则`)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return out
|
|
362
|
+
}
|
package/locale/tool/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
|
-
"title": "Preview, check and
|
|
4
|
-
"description": "Lets Standard, Creator and other modes use preview_page (open pages in the Design Studio sidebar), check_design (measure design problems at real sizes)
|
|
3
|
+
"title": "Preview, check, look and handoff tools (other modes)",
|
|
4
|
+
"description": "Lets Standard, Creator and other modes use preview_page (open pages in the Design Studio sidebar), check_design (measure design problems at real sizes), look_design (real-size screenshots for the model), extract_design_system (pull a brand design system from a site or project), and handoff_design / compare_design (build the design as real code and pixel-compare it with the design). The Design mode has its own copy and ignores this switch."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/tool/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
|
-
"title": "
|
|
4
|
-
"description": "让标准、创造等其他模式也能用 preview_page(把页面打开到右侧「设计工作台」)、check_design
|
|
3
|
+
"title": "预览、体检、截图和转代码工具(其他模式)",
|
|
4
|
+
"description": "让标准、创造等其他模式也能用 preview_page(把页面打开到右侧「设计工作台」)、check_design(按真实尺寸量一遍设计问题)、look_design(按真实尺寸截图给模型看)、extract_design_system(从网站 / 项目提取品牌设计系统)、handoff_design 和 compare_design(把设计做成真代码、和设计稿逐像素比)。设计模式里自带,不受这个开关影响。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
做网页、App 界面、平板界面、海报 / 社媒图、幻灯片、邮件、组件这类**看得见的设计**时照这份来。目标:一眼看上去像专业设计师做的,不像模板,也没有「AI 味」。
|
|
4
4
|
|
|
5
|
+
> 不归这里管:用户只是要把回答做成一页看或用(对比、表格、清单、图表、计算器、学习卡片),而你有 `show_page` 工具——直接调 `show_page`,不用这份流程、不写文件、不开预览。
|
|
6
|
+
|
|
5
7
|
## 流程
|
|
6
8
|
|
|
7
9
|
0. **新做一套设计:先问清,先出方向**。
|
|
@@ -9,6 +11,7 @@
|
|
|
9
11
|
- **什么时候跳过**:改已有的设计;用户说了「直接做」「不用问」;或者需求已经够具体(做什么、给谁用、什么风格或配色、要哪几屏都说了)。这时不问、也不出三个方向,第一句话说清你按什么理解开工(「按温暖手作风格做首页、详情、购物车三屏」),直接从第 1 步做全套。拿不准算不算够具体,就只问缺的那一两项。
|
|
10
12
|
|
|
11
13
|
- **问**:只问最关键的两三个问题,一次问完:给谁用、想要什么感觉(给三个具体选项,从工作台的风格卡里挑最合场景的三个:极简白、温暖手作、杂志编辑、深色高级、活泼年轻、清新自然、专业科技、玻璃质感、柔和立体)、有没有必须有的内容或品牌色。有 `ask_user_question` 工具就用它问(带选项),没有就在回复里问完、停下等回答。用户说「你定」就按场景替他定。
|
|
14
|
+
- **用户有默认的品牌设计系统**(这份技能末尾「设计系统」一节写着):感觉已经定了,照那一节做,不问感觉、不推风格卡。
|
|
12
15
|
- **用户点了风格卡**(消息以〔风格〕开头,带着配色、字体、形状和要点):感觉已经定了,不用再问感觉,只问还缺的(给谁用、必须有的内容);三个方向都按这套配色和字体来,在版式和信息重点上拉开。
|
|
13
16
|
- **出三个方向**:先只做最能代表这套设计的一屏(通常是首页或封面),做三个明显不同的方向:`home-a.html`、`home-b.html`、`home-c.html`。用户已经挑了感觉(比如「温暖手作」),三个方向就都在这个感觉里拉开:版式、信息重点、字体搭配、配色深浅各不相同(比如「大图海报式 / 卡片列表式 / 一步下单式」),不要再出两个他没选的风格;没挑或说「你定」,三个方向就是三种不同的感觉。不管哪种,都不是同一个版式换颜色。这时还不建 `tokens.css`,每个方向的设计变量写在自己文件的 `<style>` 里(`:root{…}`)。`<title>` 写成「方向 A · 温暖手作」这样,画布上一眼分得清。写完第一个就调 `preview_page`,另外两个会并排出现。
|
|
14
17
|
- **等用户挑**:一两句话说清三个方向各自的特点,问用户要哪个(也可以混搭:「A 的配色 + C 的版式」)。用户也可能直接在画布上点「就要这个」,消息以〔选定方向〕开头,照里面说的收尾。挑定以后:把选中方向的变量搬进 `tokens.css`,那个文件改成正式的名字(`home.html`)并改用 `tokens.css`,另外两个移进 `drafts/` 子目录(别删),然后按下面的流程做全套。
|
|
@@ -26,7 +29,7 @@
|
|
|
26
29
|
|
|
27
30
|
细则只在做那一类时再读。
|
|
28
31
|
|
|
29
|
-
2. **先写设计变量**:在设计目录建 `tokens.css`(起手模板 `tokens.css`
|
|
32
|
+
2. **先写设计变量**:在设计目录建 `tokens.css`(起手模板 `tokens.css` 就在这个技能目录里,复制过去再按品牌改;有默认的品牌设计系统就复制它的 `tokens.css` 和 `components.css`),颜色、字体、字号阶梯、间距、圆角、阴影都从这里取。每屏都 `<link rel="stylesheet" href="tokens.css">`。之后要统一改风格,改这一个文件,所有屏一起变。
|
|
30
33
|
|
|
31
34
|
3. **一屏一个 HTML 文件,放同一个目录**,按用户会看的顺序一屏一屏写(画布按创建顺序排:登录 → 首页 → 详情 → …)。文件名用英文小写(`login.html`、`home.html`),`<title>` 写这屏的中文名(画布上显示它)。临时试验的页面用 `_` 开头(`_test.html`),不会上画布。每屏 `<head>` 里写:
|
|
32
35
|
|
|
@@ -70,6 +73,28 @@
|
|
|
70
73
|
|
|
71
74
|
改完说一句改了哪几处。这一轮结束后右侧栏会自己再量一遍,按钮上的数字没了就是都改好了。
|
|
72
75
|
|
|
76
|
+
8. **调节滑块**(用户说「做几个滑块」「让我自己调」,或消息以〔调节滑块〕开头):全套做完后,挑 3–5 个这套设计最值得试的维度(留白疏密、圆角、主色、标题字号、卡片排法……),写进设计目录的 `tweaks.json`。右侧工作台把它们列成滑块,用户拖的时候所有屏一起变(只改页面上的 CSS 变量和 `<html>` 上的属性,不动文件),满意了写回文件或交给你。
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{ "tweaks": [
|
|
80
|
+
{ "id": "density", "label": "留白", "var": "--density", "min": 0.8, "max": 1.4, "step": 0.05, "value": 1, "note": "卡片和区块的内边距" },
|
|
81
|
+
{ "id": "radius", "label": "圆角", "var": "--radius-md", "min": 0, "max": 24, "unit": "px", "value": 12 },
|
|
82
|
+
{ "id": "brand", "label": "主色", "var": "--color-primary", "type": "color", "value": "#c26a32" },
|
|
83
|
+
{ "id": "layout", "label": "商品排法", "attr": "data-layout", "options": [{ "label": "两列", "value": "grid" }, { "label": "大图", "value": "list" }], "value": "grid" }
|
|
84
|
+
] }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- `var` + `min` / `max`(+ `unit`)→ 滑块;`var` + `"type": "color"` → 取色;`options` → 几选一(`var` 或 `attr`,`attr` 只能是 `data-` 开头)。`label` 两到四个字,最多 8 个。
|
|
88
|
+
- **要真接上**:每个变量在 `tokens.css` 的 `:root` 里有默认值(和 `value` 一样),样式里真用到它;倍数类的写成 `padding: calc(var(--space-4) * var(--density))`。几选一的属性:样式里给每个选项写规则(`[data-layout="list"] .grid { grid-template-columns: 1fr }`),默认选项写在每屏的 `<html data-layout="grid">` 上。
|
|
89
|
+
- 写完调 `check_design`,末尾会列出没接上的滑块,照着补。
|
|
90
|
+
- 消息以〔调节〕开头:用户拖好了值(「原来 → 现在」),照着改样式源文件,`tweaks.json` 里对应的 `value` 也改成新值;相关的地方可以顺手协调。
|
|
91
|
+
|
|
92
|
+
9. **转成代码**(用户说「做成真的项目」「转成 React / Vue」,或消息以〔转成代码〕开头):
|
|
93
|
+
- 先调 `handoff_design`(`framework`、`dest` 照用户说的或消息里的),读它写的 `handoff/HANDOFF.md`:每屏的文件和建议路由、设计变量、该抽成组件的部分、交互、素材、做法。
|
|
94
|
+
- 照「做法和验收」一屏一屏做:设计稿的 HTML 就是源码,结构、文案、样式值照搬,样式一律用设计变量;先抽共用组件再拼页面。
|
|
95
|
+
- 每做完一屏把开发服务器跑起来(后台跑),调 `compare_design`(`screen` 设计文件名,`url` 这一屏的地址),看差异图把红框里的地方改掉,差异降到 3% 以下(或剩下的差别说得出理由)再做下一屏。
|
|
96
|
+
- 设计目录里的文件别改(留着对比);做完告诉用户怎么跑起来、还有哪里和设计稿不一样。
|
|
97
|
+
|
|
73
98
|
## 设计原则
|
|
74
99
|
|
|
75
100
|
**层级**:每屏只有一个视觉焦点(最大的字 / 最亮的按钮)。用字号、字重、颜色深浅三档拉开层级,不要靠边框和分割线堆结构。主操作一个,次操作弱化成描边或文字按钮。
|
|
@@ -126,3 +151,4 @@
|
|
|
126
151
|
- [ ] 文字没有被截断、没有伸出框、没有叠在一起;页面没有比屏幕宽;滚到底最后的内容没被底栏压住(`check_design` 实测这几项)
|
|
127
152
|
- [ ] 多屏之间导航、按钮、卡片样式一致(像同一个产品)
|
|
128
153
|
- [ ] 没有上面「AI 味」清单里的东西
|
|
154
|
+
- [ ] 有 `tweaks.json` 的话:每个滑块拖了真有变化(`check_design` 末尾没有滑块的问题)
|