dsh-design-studio 0.4.1 → 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 +19 -7
- package/README.md +46 -11
- package/cordis.patch.yml +1 -1
- package/lib/browser.js +273 -0
- package/lib/bundle.js +228 -0
- package/lib/client.js +1442 -47
- package/lib/core.js +263 -25
- package/lib/doc.js +356 -0
- package/lib/handoff.js +555 -0
- package/lib/history.js +1 -1
- package/lib/index.js +683 -24
- package/lib/look.js +550 -0
- package/lib/picker.js +1153 -24
- package/lib/serve.js +25 -8
- package/lib/system.js +1360 -0
- package/lib/tool.js +9 -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 +67 -3
- package/skills/ui-design/references/app.md +1 -1
package/lib/tool.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-design-studio/tool:把 preview_page
|
|
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 } 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']
|
|
9
9
|
|
|
10
10
|
export function apply(ctx) {
|
|
11
11
|
ctx.tools.register({ ...previewTool })
|
|
12
|
+
// 截的图要存进 DSH 的附件库(ctx.get('attachments')),把这一行的 ctx 带进去
|
|
13
|
+
ctx.tools.register({ ...checkTool, execute: (args, exec) => checkTool.execute(args, exec, ctx) })
|
|
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) })
|
|
12
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
|
|
4
|
-
"description": "Lets Standard, Creator and other modes use preview_page
|
|
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
|
|
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,8 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
做网页、App 界面、平板界面、海报 / 社媒图、幻灯片、邮件、组件这类**看得见的设计**时照这份来。目标:一眼看上去像专业设计师做的,不像模板,也没有「AI 味」。
|
|
4
4
|
|
|
5
|
+
> 不归这里管:用户只是要把回答做成一页看或用(对比、表格、清单、图表、计算器、学习卡片),而你有 `show_page` 工具——直接调 `show_page`,不用这份流程、不写文件、不开预览。
|
|
6
|
+
|
|
5
7
|
## 流程
|
|
6
8
|
|
|
9
|
+
0. **新做一套设计:先问清,先出方向**。
|
|
10
|
+
|
|
11
|
+
- **什么时候跳过**:改已有的设计;用户说了「直接做」「不用问」;或者需求已经够具体(做什么、给谁用、什么风格或配色、要哪几屏都说了)。这时不问、也不出三个方向,第一句话说清你按什么理解开工(「按温暖手作风格做首页、详情、购物车三屏」),直接从第 1 步做全套。拿不准算不算够具体,就只问缺的那一两项。
|
|
12
|
+
|
|
13
|
+
- **问**:只问最关键的两三个问题,一次问完:给谁用、想要什么感觉(给三个具体选项,从工作台的风格卡里挑最合场景的三个:极简白、温暖手作、杂志编辑、深色高级、活泼年轻、清新自然、专业科技、玻璃质感、柔和立体)、有没有必须有的内容或品牌色。有 `ask_user_question` 工具就用它问(带选项),没有就在回复里问完、停下等回答。用户说「你定」就按场景替他定。
|
|
14
|
+
- **用户有默认的品牌设计系统**(这份技能末尾「设计系统」一节写着):感觉已经定了,照那一节做,不问感觉、不推风格卡。
|
|
15
|
+
- **用户点了风格卡**(消息以〔风格〕开头,带着配色、字体、形状和要点):感觉已经定了,不用再问感觉,只问还缺的(给谁用、必须有的内容);三个方向都按这套配色和字体来,在版式和信息重点上拉开。
|
|
16
|
+
- **出三个方向**:先只做最能代表这套设计的一屏(通常是首页或封面),做三个明显不同的方向:`home-a.html`、`home-b.html`、`home-c.html`。用户已经挑了感觉(比如「温暖手作」),三个方向就都在这个感觉里拉开:版式、信息重点、字体搭配、配色深浅各不相同(比如「大图海报式 / 卡片列表式 / 一步下单式」),不要再出两个他没选的风格;没挑或说「你定」,三个方向就是三种不同的感觉。不管哪种,都不是同一个版式换颜色。这时还不建 `tokens.css`,每个方向的设计变量写在自己文件的 `<style>` 里(`:root{…}`)。`<title>` 写成「方向 A · 温暖手作」这样,画布上一眼分得清。写完第一个就调 `preview_page`,另外两个会并排出现。
|
|
17
|
+
- **等用户挑**:一两句话说清三个方向各自的特点,问用户要哪个(也可以混搭:「A 的配色 + C 的版式」)。用户也可能直接在画布上点「就要这个」,消息以〔选定方向〕开头,照里面说的收尾。挑定以后:把选中方向的变量搬进 `tokens.css`,那个文件改成正式的名字(`home.html`)并改用 `tokens.css`,另外两个移进 `drafts/` 子目录(别删),然后按下面的流程做全套。
|
|
18
|
+
|
|
7
19
|
1. **先定类型和画幅**(拿不准就按用户说的场景选):
|
|
8
20
|
|
|
9
21
|
| kind | 画幅 | 外框 | 细则 |
|
|
@@ -17,22 +29,72 @@
|
|
|
17
29
|
|
|
18
30
|
细则只在做那一类时再读。
|
|
19
31
|
|
|
20
|
-
2. **先写设计变量**:在设计目录建 `tokens.css`(起手模板 `tokens.css`
|
|
32
|
+
2. **先写设计变量**:在设计目录建 `tokens.css`(起手模板 `tokens.css` 就在这个技能目录里,复制过去再按品牌改;有默认的品牌设计系统就复制它的 `tokens.css` 和 `components.css`),颜色、字体、字号阶梯、间距、圆角、阴影都从这里取。每屏都 `<link rel="stylesheet" href="tokens.css">`。之后要统一改风格,改这一个文件,所有屏一起变。
|
|
21
33
|
|
|
22
|
-
3. **一屏一个 HTML 文件,放同一个目录**,按用户会看的顺序一屏一屏写(画布按创建顺序排:登录 → 首页 → 详情 → …)。文件名用英文小写(`login.html`、`home.html`),`<title>`
|
|
34
|
+
3. **一屏一个 HTML 文件,放同一个目录**,按用户会看的顺序一屏一屏写(画布按创建顺序排:登录 → 首页 → 详情 → …)。文件名用英文小写(`login.html`、`home.html`),`<title>` 写这屏的中文名(画布上显示它)。临时试验的页面用 `_` 开头(`_test.html`),不会上画布。每屏 `<head>` 里写:
|
|
23
35
|
|
|
24
36
|
```html
|
|
25
37
|
<meta name="dsh-design" content="app"> <!-- 类型;海报可带尺寸:poster 1080x1920 -->
|
|
26
38
|
```
|
|
27
39
|
|
|
40
|
+
**几屏共用的东西只写一份**:样式放 `tokens.css` 和一个公共的 `app.css`;图标、书封这类 SVG 放进一个 `icons.svg`(每个图标一个 `<symbol id="…">`),页面里用 `<svg><use href="icons.svg#cart"></use></svg>` 引用——换风格时改一处所有屏一起变,不会漏改。状态栏、标签栏这种 HTML 结构每屏照抄一份,保持一模一样。
|
|
41
|
+
|
|
28
42
|
**几屏之间用真链接串起来**,让它成为点得动的原型:按钮、卡片、标签栏、返回箭头都写成 `<a href="detail.html">`(或 `data-href="detail.html"`)。返回也写具体去哪一屏(`<a href="home.html">`),不要用 `history.back()`。右侧画布会从按钮画一条连线到目标屏,「演示」时点按钮就跳过去(带过场动画),手机扫码看时也点得动。
|
|
29
43
|
|
|
30
44
|
4. **写完第一屏就调 `preview_page`**(`target` 填这个文件,`kind` 填类型),用户从第一屏起就能看着它长出来;后面的屏写到同一目录会自动并排出现,不用再调。
|
|
31
45
|
|
|
32
|
-
5.
|
|
46
|
+
5. **写完先量、再看,然后收尾**:
|
|
47
|
+
- **量**:调 `check_design`。它在电脑上用无头浏览器按真实尺寸打开每一屏实测(文字截断、重叠、对比度、点击区、字号、变形、行太长、固定栏压住内容、脚本报错、文件没载出来、跳到不存在的页面……),返回量了哪几屏、每种尺寸下页面多高、顶栏 / 底栏离内容多远,和问题清单(哪一屏、哪个元素、具体数字);严重的问题附了那一块的局部截图(清单里写「看图 几」,红框是出问题的元素、框上标了差多少),对着图改,不用再另外截;叠字写了「可能是 … 的 margin-top」这类原因和规则所在的行,先看那里。App 顺手量一下小屏和大屏:`sizes: ["375x667", "430x932"]`;网页量手机宽:`sizes: ["390", "1280"]`。
|
|
48
|
+
- **看**:调 `look_design` 看一眼真实渲染的截图,检查量不出来的东西(层次、留白、配色、图片、整体观感);底栏多的 App 用 `at: "bottom"` 看滚到底的样子,长页面用 `at: "full"`,只看某一块用 `selector`。
|
|
49
|
+
- **改**:照第 7 步的办法改,改完只重量改过的屏(`screens` 只填那几屏)。
|
|
50
|
+
- **什么时候算做完**:`check_design` 没有问题(或剩下的你判断不该改,说得出理由),每屏都看过一眼截图。量 / 看 / 改最多三轮:三轮后还有的,照实列给用户,别一直磨。不要自己另写检查页、起服务、开浏览器截图——上面两个工具就是干这个的,又快又准。
|
|
51
|
+
- 最后用一两句话告诉用户做了哪几屏、风格要点、还剩什么没解决。
|
|
33
52
|
|
|
34
53
|
6. **用户点选后让你改**(消息里有〔选中元素〕/〔选中区域〕):按里面的文件和 CSS 路径找到源码改那一处;如果是颜色、字号、圆角这类风格问题,优先改 `tokens.css` 里的变量,让所有屏保持一致。
|
|
35
54
|
|
|
55
|
+
**用户在工作台里手调过样式**:元素上的 `style="…"` 多半是用户用「调样式」拖出来的,值是用户要的,别随手删、也别改回去;整理代码时可以挪进对应的样式规则(值不变)。消息以〔调样式〕开头时,按里面的元素、选择器和「原来 → 现在」写进样式表,值是 `var(--x)` 的就用那个变量;同类元素(比如所有主按钮)该一起改就一起改,改完说一句。
|
|
56
|
+
|
|
57
|
+
**换风格**(〔风格〕后面说「把现在的设计换成这个风格」):先改 `tokens.css`(配色、字体、圆角、阴影按消息里的来),页面里写死的颜色换成变量,再按「要点」调细节;布局和文案不动。改完调 `check_design` 量一遍(换了配色最容易出对比度问题)。
|
|
58
|
+
|
|
59
|
+
7. **改体检出来的问题**(`check_design` 的结果,或用户发来的〔设计体检〕):右侧工作台在渲染好的页面上实测出来的,按里面的文件、选择器和数字逐条改,没列出来的地方别动:
|
|
60
|
+
- 对比度不够:调深文字色(或调浅底色),能改 `tokens.css` 里的 `muted`、主色上的文字色就改变量;
|
|
61
|
+
- 文字被截断 / 超出框:放宽容器、允许换行或去掉写死的高度,别靠缩小字号硬塞;
|
|
62
|
+
- 文字叠在一起:检查绝对定位和负边距,让它们回到正常排版;
|
|
63
|
+
- 点击区太小:加内边距或 `min-height` / `min-width`,图标按钮的点击区可以比图标本身大;
|
|
64
|
+
- 超出屏幕:找写死的宽度(`width: 600px`、`100vw` 加内边距),改成 `max-width: 100%` 或自适应;
|
|
65
|
+
- 字太小、字号太杂:收到字号阶梯里的几档;
|
|
66
|
+
- 图片被拉变形:加 `object-fit: cover`(要完整显示就 `contain`),或按原图比例改宽高;
|
|
67
|
+
- 一行太长:给正文容器加 `max-width`(中文约 `36em`,英文约 `65ch`),别靠缩小字号。
|
|
68
|
+
- 文字被挤断行:按钮、价格、标签这种短字给足宽度,或加 `white-space: nowrap`(在 flex 里再加 `flex-shrink: 0`);一句短话最后一行只剩一两个字,就调宽度或改字。
|
|
69
|
+
- 被固定栏压住 / 离固定栏太近:给内容留出栏的高度——底栏是 `position: fixed` 的,给 `body` 或内容容器加 `padding-bottom: calc(栏高 + 16px)`(App 再加 `env(safe-area-inset-bottom)`);内容在能滚的 `main` 里,就给 `main` 加 `padding-bottom`。顶栏同理用 `padding-top`。
|
|
70
|
+
- 页面脚本报错:按报的文件和行号修;纯展示用的设计稿能不写脚本就不写。
|
|
71
|
+
- 文件没载出来:路径写错了(大小写、相对路径),或者文件还没建,补上或改对。
|
|
72
|
+
- 跳到的页面不存在:补上那一屏,或者把链接改成已有的屏。
|
|
73
|
+
|
|
74
|
+
改完说一句改了哪几处。这一轮结束后右侧栏会自己再量一遍,按钮上的数字没了就是都改好了。
|
|
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
|
+
|
|
36
98
|
## 设计原则
|
|
37
99
|
|
|
38
100
|
**层级**:每屏只有一个视觉焦点(最大的字 / 最亮的按钮)。用字号、字重、颜色深浅三档拉开层级,不要靠边框和分割线堆结构。主操作一个,次操作弱化成描边或文字按钮。
|
|
@@ -86,5 +148,7 @@
|
|
|
86
148
|
- [ ] 文案是真实的中文内容,没有占位文字、没有错别字
|
|
87
149
|
- [ ] 图标同一套风格,没有 emoji 图标
|
|
88
150
|
- [ ] 画幅对:App 留出顶部 54px 状态栏和底部 34px 横条;海报 / 幻灯片内容在安全区内不溢出
|
|
151
|
+
- [ ] 文字没有被截断、没有伸出框、没有叠在一起;页面没有比屏幕宽;滚到底最后的内容没被底栏压住(`check_design` 实测这几项)
|
|
89
152
|
- [ ] 多屏之间导航、按钮、卡片样式一致(像同一个产品)
|
|
90
153
|
- [ ] 没有上面「AI 味」清单里的东西
|
|
154
|
+
- [ ] 有 `tweaks.json` 的话:每个滑块拖了真有变化(`check_design` 末尾没有滑块的问题)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 画幅
|
|
4
4
|
|
|
5
|
-
- 手机按 390×844(iPhone 15/16
|
|
5
|
+
- 手机按 390×844(iPhone 15/16)设计,页面宽度跟着视口走(`width: 100%`),不要居中一个小盒子。`<meta name="viewport" content="width=device-width,initial-scale=1">`。别写死 `width: 390px`、也别给 body 加 `max-width` 再居中:工作台已经按 390 宽显示、套好手机外框,多尺寸视图还会拿 375 / 430 宽来看,写死就露馅。也别为了这个另开浏览器截图——要检查就调 `check_design`。
|
|
6
6
|
- 顶部 54px 是状态栏 + 灵动岛:自己画一条状态栏(时间 9:41、信号、电量),中间空着——灵动岛预览的外框会画,页面里别再画一个(不然换尺寸看时会出现两个)。内容从 54px 以下开始。
|
|
7
7
|
- 底部 34px 是横条(home indicator)区:底部的标签栏 / 按钮要把它让出来(标签栏总高约 83px)。
|
|
8
8
|
- 左右边距 16–24px;卡片之间 12–16px。
|