@easysociable/cli 0.1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/easysociable.js +15 -0
- package/dist/easysociable.js +35464 -0
- package/dist/easysociable.js.map +1 -0
- package/package.json +58 -0
- package/skills/ONBOARDING.md +61 -0
- package/skills/easysociable-content/SKILL.md +115 -0
- package/skills/easysociable-content/platforms/claude.md +5 -0
- package/skills/easysociable-content/platforms/codex.md +5 -0
- package/skills/easysociable-content/platforms/hermes.md +5 -0
- package/skills/easysociable-content/platforms/openclaw.md +5 -0
- package/skills/easysociable-content/references/CLI.md +27 -0
- package/skills/easysociable-content/references/MCP.md +20 -0
- package/skills/easysociable-content/references/PAGE_PACK.md +6 -0
- package/skills/easysociable-content/references/TROUBLESHOOTING.md +11 -0
- package/skills/easysociable-content/references/VAULT.md +20 -0
- package/skills/easysociable-content/scripts/setup.mjs +156 -0
- package/skills/easysociable-content/scripts/setup.test.mjs +71 -0
- package/skills/easysociable-content/scripts/starter.mjs +53 -0
- package/skills/easysociable-content/workflows/ANALYSIS_AND_COVERAGE.md +8 -0
- package/skills/easysociable-content/workflows/CONTENT_EXPORT.md +19 -0
- package/skills/easysociable-content/workflows/DEVICE_TASKS.md +5 -0
- package/skills/easysociable-content/workflows/EXPERIMENTS.md +6 -0
- package/skills/easysociable-content/workflows/MCP_FIRST.md +10 -0
- package/skills/easysociable-content/workflows/PUBLICATION.md +5 -0
- package/skills/easysociable-content/workflows/TOPIC_DRAFT.md +5 -0
- package/skills/easysociable-content/workflows/WORKING_DRAFT.md +10 -0
- package/skills/easysociable-slideshow/SKILL.md +77 -0
- package/skills/easysociable-slideshow/references/CONTENT_PACK.md +25 -0
- package/skills/easysociable-slideshow/references/GATEWAY.md +16 -0
- package/skills/easysociable-slideshow/references/PLATFORM_CAPS.md +17 -0
- package/skills/easysociable-slideshow/references/VAULT.md +18 -0
- package/skills/easysociable-slideshow/workflows/CREATE_RUN.md +7 -0
- package/skills/easysociable-slideshow/workflows/MCP_FIRST.md +14 -0
- package/skills/easysociable-slideshow/workflows/OUT_OF_SCOPE.md +8 -0
- package/skills/easysociable-slideshow/workflows/PUBLISH_RUN.md +4 -0
- package/skills/easysociable-slideshow/workflows/RENDER_RUN.md +6 -0
- package/skills/easysociable-template/SKILL.md +106 -0
- package/skills/easysociable-template/references/AUTHORING_SOURCE.md +129 -0
- package/skills/easysociable-template/references/CATALOG_METADATA.md +221 -0
- package/skills/easysociable-template/references/DESIGN_INTELLIGENCE.md +69 -0
- package/skills/easysociable-template/references/DESIGN_V2_1.md +278 -0
- package/skills/easysociable-template/references/EXTERNAL_RESOURCES.md +52 -0
- package/skills/easysociable-template/references/GATEWAY.md +25 -0
- package/skills/easysociable-template/references/PLATFORM_SPECS.md +134 -0
- package/skills/easysociable-template/references/SLOTS.md +104 -0
- package/skills/easysociable-template/references/THEME_PRESETS.md +48 -0
- package/skills/easysociable-template/references/THEME_TOKENS.md +89 -0
- package/skills/easysociable-template/references/fixtures/surface-gradient.design-plan.json +74 -0
- package/skills/easysociable-template/references/theme-presets.json +95 -0
- package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +402 -0
- package/skills/easysociable-template/scripts/validate-authoring-source.mjs +228 -0
- package/skills/easysociable-template/scripts/validate-design-v2.mjs +318 -0
- package/skills/easysociable-template/workflows/ASSET_PLAN.md +34 -0
- package/skills/easysociable-template/workflows/AUTHORING_FROM_BRIEF.md +52 -0
- package/skills/easysociable-template/workflows/AUTHORING_FROM_IMAGE.md +52 -0
- package/skills/easysociable-template/workflows/AUTHORING_STUDIO.md +122 -0
- package/skills/easysociable-template/workflows/DESIGN_BRIEF.md +92 -0
- package/skills/easysociable-template/workflows/DESIGN_CONCEPTS.md +41 -0
- package/skills/easysociable-template/workflows/DESIGN_PHILOSOPHY.md +69 -0
- package/skills/easysociable-template/workflows/FILL_AND_RENDER.md +86 -0
- package/skills/easysociable-template/workflows/MCP_FIRST.md +9 -0
- package/skills/easysociable-template/workflows/OUT_OF_SCOPE.md +15 -0
- package/skills/easysociable-template/workflows/PALETTE_REVIEW.md +71 -0
- package/skills/easysociable-template/workflows/PUBLISH_FROM_DRAFT.md +60 -0
- package/skills/easysociable-template/workflows/SURFACE_REVIEW.md +178 -0
- package/skills/easysociable-template/workflows/THEME_FROM_BRAND.md +73 -0
- package/skills/easysociable-template/workflows/VISUAL_QA.md +55 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const SHADE_SPECS = {
|
|
8
|
+
50: { l: 97, sMul: 0.8 },
|
|
9
|
+
100: { l: 94, sMul: 0.8 },
|
|
10
|
+
200: { l: 87, sMul: 0.85 },
|
|
11
|
+
300: { l: 75, sMul: 0.9 },
|
|
12
|
+
400: { l: 62, sMul: 0.95 },
|
|
13
|
+
500: { l: 48, sMul: 1.0 },
|
|
14
|
+
600: { l: 40, sMul: 1.0 },
|
|
15
|
+
700: { l: 33, sMul: 1.0 },
|
|
16
|
+
800: { l: 27, sMul: 1.0 },
|
|
17
|
+
900: { l: 20, sMul: 1.0 },
|
|
18
|
+
950: { l: 10, sMul: 1.0 },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SEMANTIC_LIGHT = {
|
|
22
|
+
canvas: '#FFFFFF',
|
|
23
|
+
text: '950',
|
|
24
|
+
accent: '700',
|
|
25
|
+
muted: '800',
|
|
26
|
+
primary: '800',
|
|
27
|
+
border: '200',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SEMANTIC_DARK = {
|
|
31
|
+
canvas: '950',
|
|
32
|
+
text: '50',
|
|
33
|
+
accent: '400',
|
|
34
|
+
muted: '400',
|
|
35
|
+
primary: '700',
|
|
36
|
+
border: '800',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const options = {
|
|
41
|
+
hex: null,
|
|
42
|
+
mode: null,
|
|
43
|
+
heading: null,
|
|
44
|
+
body: null,
|
|
45
|
+
output: null,
|
|
46
|
+
preset: null,
|
|
47
|
+
listPresets: false,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
51
|
+
const arg = argv[i]
|
|
52
|
+
if (arg === '--mode') {
|
|
53
|
+
options.mode = argv[++i]
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (arg === '--heading') {
|
|
57
|
+
options.heading = argv[++i]
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (arg === '--body') {
|
|
61
|
+
options.body = argv[++i]
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (arg === '--output' || arg === '-o') {
|
|
65
|
+
options.output = argv[++i]
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (arg === '--preset') {
|
|
69
|
+
options.preset = argv[++i]
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (arg === '--list-presets') {
|
|
73
|
+
options.listPresets = true
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (arg.startsWith('#'))
|
|
77
|
+
options.hex = arg
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return options
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function loadPresets() {
|
|
84
|
+
const presetsPath = path.resolve(
|
|
85
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
86
|
+
'../references/theme-presets.json',
|
|
87
|
+
)
|
|
88
|
+
const raw = JSON.parse(fs.readFileSync(presetsPath, 'utf8'))
|
|
89
|
+
return raw.presets ?? []
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolvePreset(presetId) {
|
|
93
|
+
const presets = loadPresets()
|
|
94
|
+
const preset = presets.find((item) => item.id === presetId)
|
|
95
|
+
if (!preset)
|
|
96
|
+
throw new Error(`Unknown preset "${presetId}". Use --list-presets.`)
|
|
97
|
+
return preset
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function applyPresetDefaults(options) {
|
|
101
|
+
if (!options.preset)
|
|
102
|
+
return {
|
|
103
|
+
...options,
|
|
104
|
+
mode: options.mode ?? 'light',
|
|
105
|
+
heading: options.heading ?? 'Inter',
|
|
106
|
+
body: options.body ?? 'Inter',
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const preset = resolvePreset(options.preset)
|
|
110
|
+
return {
|
|
111
|
+
...options,
|
|
112
|
+
hex: options.hex ?? preset.brandHex,
|
|
113
|
+
mode: options.mode ?? preset.mode ?? 'light',
|
|
114
|
+
heading: options.heading ?? preset.fonts?.heading ?? 'Inter',
|
|
115
|
+
body: options.body ?? preset.fonts?.body ?? 'Inter',
|
|
116
|
+
presetMeta: {
|
|
117
|
+
id: preset.id,
|
|
118
|
+
name: preset.name,
|
|
119
|
+
bestFor: preset.bestFor,
|
|
120
|
+
notFor: preset.notFor,
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hexToHsl(hex) {
|
|
126
|
+
const normalized = hex.replace(/^#/, '')
|
|
127
|
+
if (!/^[0-9a-fA-F]{6}$/.test(normalized))
|
|
128
|
+
throw new Error(`Invalid hex color: ${hex}`)
|
|
129
|
+
|
|
130
|
+
const r = Number.parseInt(normalized.slice(0, 2), 16) / 255
|
|
131
|
+
const g = Number.parseInt(normalized.slice(2, 4), 16) / 255
|
|
132
|
+
const b = Number.parseInt(normalized.slice(4, 6), 16) / 255
|
|
133
|
+
const max = Math.max(r, g, b)
|
|
134
|
+
const min = Math.min(r, g, b)
|
|
135
|
+
const diff = max - min
|
|
136
|
+
let l = (max + min) / 2
|
|
137
|
+
let s = 0
|
|
138
|
+
|
|
139
|
+
if (diff !== 0)
|
|
140
|
+
s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min)
|
|
141
|
+
|
|
142
|
+
let h = 0
|
|
143
|
+
if (diff !== 0) {
|
|
144
|
+
if (max === r)
|
|
145
|
+
h = ((g - b) / diff + (g < b ? 6 : 0)) / 6
|
|
146
|
+
else if (max === g)
|
|
147
|
+
h = ((b - r) / diff + 2) / 6
|
|
148
|
+
else
|
|
149
|
+
h = ((r - g) / diff + 4) / 6
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
h: Math.round(h * 360),
|
|
154
|
+
s: Math.round(s * 100),
|
|
155
|
+
l: Math.round(l * 100),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hslToHex(h, s, l) {
|
|
160
|
+
const sat = s / 100
|
|
161
|
+
const light = l / 100
|
|
162
|
+
const c = (1 - Math.abs(2 * light - 1)) * sat
|
|
163
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
164
|
+
const m = light - c / 2
|
|
165
|
+
let r = 0
|
|
166
|
+
let g = 0
|
|
167
|
+
let b = 0
|
|
168
|
+
|
|
169
|
+
if (h < 60) {
|
|
170
|
+
r = c
|
|
171
|
+
g = x
|
|
172
|
+
}
|
|
173
|
+
else if (h < 120) {
|
|
174
|
+
r = x
|
|
175
|
+
g = c
|
|
176
|
+
}
|
|
177
|
+
else if (h < 180) {
|
|
178
|
+
g = c
|
|
179
|
+
b = x
|
|
180
|
+
}
|
|
181
|
+
else if (h < 240) {
|
|
182
|
+
g = x
|
|
183
|
+
b = c
|
|
184
|
+
}
|
|
185
|
+
else if (h < 300) {
|
|
186
|
+
r = x
|
|
187
|
+
b = c
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
r = c
|
|
191
|
+
b = x
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const toHex = (value) => Math.round((value + m) * 255).toString(16).padStart(2, '0')
|
|
195
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function generateScale(brandHex) {
|
|
199
|
+
const { h, s } = hexToHsl(brandHex)
|
|
200
|
+
const scale = {}
|
|
201
|
+
|
|
202
|
+
for (const [shade, { l, sMul }] of Object.entries(SHADE_SPECS))
|
|
203
|
+
scale[shade] = hslToHex(h, Math.round(s * sMul), l)
|
|
204
|
+
|
|
205
|
+
return scale
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveSemantic(scale, mapping) {
|
|
209
|
+
const colors = {}
|
|
210
|
+
for (const [token, value] of Object.entries(mapping)) {
|
|
211
|
+
if (value.startsWith('#'))
|
|
212
|
+
colors[token] = value
|
|
213
|
+
else
|
|
214
|
+
colors[token] = scale[value]
|
|
215
|
+
}
|
|
216
|
+
return colors
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getLuminance(hex) {
|
|
220
|
+
const normalized = hex.replace(/^#/, '')
|
|
221
|
+
const channels = [
|
|
222
|
+
Number.parseInt(normalized.slice(0, 2), 16) / 255,
|
|
223
|
+
Number.parseInt(normalized.slice(2, 4), 16) / 255,
|
|
224
|
+
Number.parseInt(normalized.slice(4, 6), 16) / 255,
|
|
225
|
+
].map((channel) => (
|
|
226
|
+
channel <= 0.03928
|
|
227
|
+
? channel / 12.92
|
|
228
|
+
: ((channel + 0.055) / 1.055) ** 2.4
|
|
229
|
+
))
|
|
230
|
+
|
|
231
|
+
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getContrastRatio(foreground, background) {
|
|
235
|
+
const lighter = Math.max(getLuminance(foreground), getLuminance(background))
|
|
236
|
+
const darker = Math.min(getLuminance(foreground), getLuminance(background))
|
|
237
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildContrastReport(light, dark) {
|
|
241
|
+
const modes = [
|
|
242
|
+
{ mode: 'light', palette: light, buttonForeground: light.canvas },
|
|
243
|
+
{ mode: 'dark', palette: dark, buttonForeground: dark.text },
|
|
244
|
+
]
|
|
245
|
+
const pairs = [
|
|
246
|
+
{ pair: 'text/canvas', foregroundKey: 'text', backgroundKey: 'canvas', minAA: 4.5 },
|
|
247
|
+
{ pair: 'muted/canvas', foregroundKey: 'muted', backgroundKey: 'canvas', minAA: 4.5 },
|
|
248
|
+
{ pair: 'accent/canvas', foregroundKey: 'accent', backgroundKey: 'canvas', minAA: 3 },
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
return modes.flatMap(({ mode, palette, buttonForeground }) => {
|
|
252
|
+
const base = pairs.map(({ pair, foregroundKey, backgroundKey, minAA }) => {
|
|
253
|
+
const foreground = palette[foregroundKey]
|
|
254
|
+
const background = palette[backgroundKey]
|
|
255
|
+
const ratio = Number(getContrastRatio(foreground, background).toFixed(2))
|
|
256
|
+
return {
|
|
257
|
+
pair,
|
|
258
|
+
mode,
|
|
259
|
+
foreground,
|
|
260
|
+
background,
|
|
261
|
+
ratio,
|
|
262
|
+
wcagAA: ratio >= minAA,
|
|
263
|
+
wcagAAA: ratio >= 7,
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
const buttonRatio = Number(getContrastRatio(buttonForeground, palette.primary).toFixed(2))
|
|
267
|
+
base.push({
|
|
268
|
+
pair: 'label/primary',
|
|
269
|
+
mode,
|
|
270
|
+
foreground: buttonForeground,
|
|
271
|
+
background: palette.primary,
|
|
272
|
+
ratio: buttonRatio,
|
|
273
|
+
wcagAA: buttonRatio >= 3,
|
|
274
|
+
wcagAAA: buttonRatio >= 7,
|
|
275
|
+
})
|
|
276
|
+
return base
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildThemeTokens(colors, fonts, surfaces, palette) {
|
|
281
|
+
return {
|
|
282
|
+
colors: {
|
|
283
|
+
canvas: colors.canvas,
|
|
284
|
+
text: colors.text,
|
|
285
|
+
accent: colors.accent,
|
|
286
|
+
muted: colors.muted,
|
|
287
|
+
primary: colors.primary,
|
|
288
|
+
border: colors.border,
|
|
289
|
+
},
|
|
290
|
+
fonts: {
|
|
291
|
+
heading: fonts.heading,
|
|
292
|
+
body: fonts.body,
|
|
293
|
+
},
|
|
294
|
+
surfaces: {
|
|
295
|
+
radius: surfaces.radius,
|
|
296
|
+
shadow: surfaces.shadow,
|
|
297
|
+
},
|
|
298
|
+
palette,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function generateThemeTokens(options) {
|
|
303
|
+
const scale = generateScale(options.hex)
|
|
304
|
+
const light = resolveSemantic(scale, SEMANTIC_LIGHT)
|
|
305
|
+
const dark = resolveSemantic(scale, SEMANTIC_DARK)
|
|
306
|
+
const contrast = buildContrastReport(light, dark)
|
|
307
|
+
const surfaces = {
|
|
308
|
+
radius: '12px',
|
|
309
|
+
shadow: options.mode === 'dark'
|
|
310
|
+
? '0 8px 24px rgba(0,0,0,.45)'
|
|
311
|
+
: '0 8px 24px rgba(0,0,0,.12)',
|
|
312
|
+
}
|
|
313
|
+
const fonts = { heading: options.heading, body: options.body }
|
|
314
|
+
const activePalette = options.mode === 'dark' ? dark : light
|
|
315
|
+
const palette = {
|
|
316
|
+
brandHex: options.hex.toUpperCase(),
|
|
317
|
+
scale,
|
|
318
|
+
activeMode: options.mode,
|
|
319
|
+
semantic: { light, dark },
|
|
320
|
+
contrast,
|
|
321
|
+
contrastPass: contrast.every((entry) => entry.wcagAA),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
schemaVersion: 'theme_tokens_v2',
|
|
326
|
+
brandHex: options.hex.toUpperCase(),
|
|
327
|
+
scale,
|
|
328
|
+
semantic: { light, dark },
|
|
329
|
+
activeMode: options.mode,
|
|
330
|
+
preset: options.presetMeta ?? null,
|
|
331
|
+
themeTokens: buildThemeTokens(activePalette, fonts, surfaces, palette),
|
|
332
|
+
themeTokensLight: buildThemeTokens(light, fonts, surfaces, { ...palette, activeMode: 'light' }),
|
|
333
|
+
themeTokensDark: buildThemeTokens(dark, fonts, surfaces, { ...palette, activeMode: 'dark' }),
|
|
334
|
+
contrast,
|
|
335
|
+
contrastPass: palette.contrastPass,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function printSummary(result) {
|
|
340
|
+
if (result.preset)
|
|
341
|
+
console.log(`Preset: ${result.preset.id} (${result.preset.name})`)
|
|
342
|
+
console.log(`Brand: ${result.brandHex}`)
|
|
343
|
+
console.log(`Active mode: ${result.activeMode}`)
|
|
344
|
+
console.log(`Contrast pass (AA): ${result.contrastPass ? 'yes' : 'no'}`)
|
|
345
|
+
console.log('Scale:')
|
|
346
|
+
for (const shade of Object.keys(SHADE_SPECS))
|
|
347
|
+
console.log(` ${shade}: ${result.scale[shade]}`)
|
|
348
|
+
console.log('ThemeTokens (active):')
|
|
349
|
+
console.log(JSON.stringify(result.themeTokens, null, 2))
|
|
350
|
+
if (!result.contrastPass) {
|
|
351
|
+
console.error('Contrast failures:')
|
|
352
|
+
result.contrast
|
|
353
|
+
.filter((entry) => !entry.wcagAA)
|
|
354
|
+
.forEach((entry) => console.error(` - [${entry.mode}] ${entry.pair}: ${entry.ratio}:1`))
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function main() {
|
|
359
|
+
const raw = parseArgs(process.argv.slice(2))
|
|
360
|
+
if (raw.listPresets) {
|
|
361
|
+
loadPresets().forEach((preset) => {
|
|
362
|
+
console.log(`${preset.id}\t${preset.name}\t${preset.brandHex}\t${preset.mode}\t${preset.bestFor}`)
|
|
363
|
+
})
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let options
|
|
368
|
+
try {
|
|
369
|
+
options = applyPresetDefaults(raw)
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
console.error(error.message)
|
|
373
|
+
process.exit(2)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!options.hex) {
|
|
377
|
+
console.error('Usage: node generate-theme-from-hex.mjs <#hex|--preset id> [--mode light|dark] [--heading Font] [--body Font] [--output path]')
|
|
378
|
+
console.error(' node generate-theme-from-hex.mjs --list-presets')
|
|
379
|
+
process.exit(2)
|
|
380
|
+
}
|
|
381
|
+
if (!['light', 'dark'].includes(options.mode)) {
|
|
382
|
+
console.error('--mode must be light or dark')
|
|
383
|
+
process.exit(2)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const result = generateThemeTokens(options)
|
|
387
|
+
const json = `${JSON.stringify(result, null, 2)}\n`
|
|
388
|
+
|
|
389
|
+
if (options.output) {
|
|
390
|
+
fs.writeFileSync(path.resolve(options.output), json)
|
|
391
|
+
console.log(`Wrote ${path.resolve(options.output)}`)
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
process.stdout.write(json)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
printSummary(result)
|
|
398
|
+
if (!result.contrastPass)
|
|
399
|
+
process.exit(1)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
main()
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
function fail(message) {
|
|
6
|
+
process.stderr.write(`authoring-source invalid: ${message}\n`)
|
|
7
|
+
process.exitCode = 1
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readInput(path) {
|
|
11
|
+
if (path === '-')
|
|
12
|
+
return readFileSync(0, 'utf8')
|
|
13
|
+
return readFileSync(path, 'utf8')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function arbitraryTokens(className) {
|
|
17
|
+
return className
|
|
18
|
+
.split(/\s+/)
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.filter(token => token.includes('[') && token.includes(']'))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function inlineStyleSource(tag) {
|
|
24
|
+
const objectStyle = tag.match(/\bstyle\s*=\s*\{\{([\s\S]*?)\}\}/)
|
|
25
|
+
if (objectStyle)
|
|
26
|
+
return objectStyle[1]
|
|
27
|
+
|
|
28
|
+
const stringStyle = tag.match(/\bstyle\s*=\s*["']([^"']*)["']/)
|
|
29
|
+
return stringStyle?.[1] ?? ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function styleEntriesToSource(style) {
|
|
33
|
+
if (!style || typeof style !== 'object')
|
|
34
|
+
return ''
|
|
35
|
+
return Object.entries(style).map(([key, item]) => `${key}:${String(item)}`).join(';')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isAbsolutelyPositionedStyle(styleSource) {
|
|
39
|
+
return /\bposition\s*:\s*['"]?absolute\b/i.test(styleSource)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function checkClassName(className, location, styleSource = '') {
|
|
43
|
+
const arbitrary = arbitraryTokens(className)
|
|
44
|
+
if (arbitrary.length > 0) {
|
|
45
|
+
fail(
|
|
46
|
+
`${location} uses runtime Tailwind arbitrary-value classes: ${arbitrary.join(', ')}. `
|
|
47
|
+
+ 'Move exact geometry, typography, and Theme values into inline style.',
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (className.split(/\s+/).includes('absolute')) {
|
|
52
|
+
const hasInlinePosition = /\bposition\s*:\s*['"]?absolute\b/.test(styleSource)
|
|
53
|
+
const hasInlineCoordinate = /\b(?:left|right|top|bottom)\s*:/.test(styleSource)
|
|
54
|
+
if (!hasInlinePosition || !hasInlineCoordinate) {
|
|
55
|
+
fail(
|
|
56
|
+
`${location} relies on className="absolute" without inline position and coordinates. `
|
|
57
|
+
+ 'Runtime authoring geometry must be deterministic inline style.',
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tagNameFromJsx(tag) {
|
|
64
|
+
const match = tag.match(/^<\/?\s*([A-Za-z][\w:-]*)/)
|
|
65
|
+
return match?.[1]?.toLowerCase() ?? 'unknown'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Flat-tree rule: absolutely positioned nodes must be canvas siblings.
|
|
70
|
+
* Nesting absolute inside absolute breaks Studio drag/select coordinates.
|
|
71
|
+
* Non-positioned text/inline children under a text unit remain allowed.
|
|
72
|
+
*/
|
|
73
|
+
function checkJsxFlatNesting(jsx) {
|
|
74
|
+
const tags = jsx.match(/<\/?[A-Za-z][\s\S]*?>/g) ?? []
|
|
75
|
+
const stack = []
|
|
76
|
+
|
|
77
|
+
tags.forEach((tag, index) => {
|
|
78
|
+
const name = tagNameFromJsx(tag)
|
|
79
|
+
const location = `json tag ${index + 1} (<${name}>)`
|
|
80
|
+
|
|
81
|
+
if (tag.startsWith('</')) {
|
|
82
|
+
while (stack.length > 0) {
|
|
83
|
+
const top = stack.pop()
|
|
84
|
+
if (top.name === name)
|
|
85
|
+
break
|
|
86
|
+
}
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const styleSource = inlineStyleSource(tag)
|
|
91
|
+
const absolute = isAbsolutelyPositionedStyle(styleSource)
|
|
92
|
+
if (absolute) {
|
|
93
|
+
const absAncestor = [...stack].reverse().find(frame => frame.absolute)
|
|
94
|
+
if (absAncestor) {
|
|
95
|
+
fail(
|
|
96
|
+
`${location} is absolutely positioned inside another absolute node (<${absAncestor.name}>). `
|
|
97
|
+
+ 'Flatten to canvas siblings with canvas coordinates. '
|
|
98
|
+
+ 'Only non-positioned text/inline children may nest under a text unit; '
|
|
99
|
+
+ 'card chrome must be a separate decoration sibling.',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const selfClosing = /\/\s*>$/.test(tag) || /^<(?:img|br|hr|input|meta|link)\b/i.test(tag)
|
|
105
|
+
if (!selfClosing)
|
|
106
|
+
stack.push({ name, absolute })
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function checkJsxString(jsx) {
|
|
111
|
+
const tags = jsx.match(/<[A-Za-z][\s\S]*?>/g) ?? []
|
|
112
|
+
tags.forEach((tag, index) => {
|
|
113
|
+
const classMatch = tag.match(/\bclass(?:Name)?\s*=\s*["']([^"']*)["']/)
|
|
114
|
+
if (!classMatch)
|
|
115
|
+
return
|
|
116
|
+
checkClassName(classMatch[1], `json tag ${index + 1}`, inlineStyleSource(tag))
|
|
117
|
+
})
|
|
118
|
+
checkJsxFlatNesting(jsx)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nodeTagName(value) {
|
|
122
|
+
const raw = value.tagName || value.type || value.name
|
|
123
|
+
return typeof raw === 'string' ? raw.toLowerCase() : 'node'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function nodeStyleSource(value) {
|
|
127
|
+
const fromProps = value.props?.style
|
|
128
|
+
if (fromProps && typeof fromProps === 'object')
|
|
129
|
+
return styleEntriesToSource(fromProps)
|
|
130
|
+
if (value.style && typeof value.style === 'object')
|
|
131
|
+
return styleEntriesToSource(value.style)
|
|
132
|
+
return ''
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function nodeClassName(value) {
|
|
136
|
+
if (typeof value.className === 'string')
|
|
137
|
+
return value.className
|
|
138
|
+
if (typeof value.class === 'string')
|
|
139
|
+
return value.class
|
|
140
|
+
if (typeof value.props?.className === 'string')
|
|
141
|
+
return value.props.className
|
|
142
|
+
if (typeof value.props?.class === 'string')
|
|
143
|
+
return value.props.class
|
|
144
|
+
return ''
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function checkObjectFlatNesting(value, path = 'json', ancestorAbsolute = false) {
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
value.forEach((item, index) => checkObjectFlatNesting(item, `${path}[${index}]`, ancestorAbsolute))
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (!value || typeof value !== 'object')
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
const styleSource = nodeStyleSource(value)
|
|
156
|
+
const absolute = isAbsolutelyPositionedStyle(styleSource)
|
|
157
|
+
if (absolute && ancestorAbsolute) {
|
|
158
|
+
fail(
|
|
159
|
+
`${path} (<${nodeTagName(value)}>) is absolutely positioned inside another absolute node. `
|
|
160
|
+
+ 'Flatten to canvas siblings with canvas coordinates. '
|
|
161
|
+
+ 'Only non-positioned text/inline children may nest under a text unit.',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const childAncestorAbsolute = ancestorAbsolute || absolute
|
|
166
|
+
const children = value.children ?? value.props?.children
|
|
167
|
+
if (Array.isArray(children))
|
|
168
|
+
checkObjectFlatNesting(children, `${path}.children`, childAncestorAbsolute)
|
|
169
|
+
else if (children && typeof children === 'object')
|
|
170
|
+
checkObjectFlatNesting(children, `${path}.children`, childAncestorAbsolute)
|
|
171
|
+
|
|
172
|
+
Object.entries(value).forEach(([key, item]) => {
|
|
173
|
+
if (['children', 'props', 'style', 'className', 'class'].includes(key))
|
|
174
|
+
return
|
|
175
|
+
if (item && typeof item === 'object')
|
|
176
|
+
checkObjectFlatNesting(item, `${path}.${key}`, false)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function checkObjectProjection(value, path = 'json') {
|
|
181
|
+
if (Array.isArray(value)) {
|
|
182
|
+
value.forEach((item, index) => checkObjectProjection(item, `${path}[${index}]`))
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!value || typeof value !== 'object')
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
const className = nodeClassName(value)
|
|
189
|
+
const style = nodeStyleSource(value)
|
|
190
|
+
if (className)
|
|
191
|
+
checkClassName(className, path, style)
|
|
192
|
+
|
|
193
|
+
Object.entries(value).forEach(([key, item]) => {
|
|
194
|
+
if (!['className', 'class', 'style'].includes(key))
|
|
195
|
+
checkObjectProjection(item, `${path}.${key}`)
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const inputPath = process.argv[2]
|
|
200
|
+
if (!inputPath) {
|
|
201
|
+
process.stderr.write('Usage: node validate-authoring-source.mjs <authoring-source.json|->\n')
|
|
202
|
+
process.exit(2)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let source
|
|
206
|
+
try {
|
|
207
|
+
source = JSON.parse(readInput(inputPath))
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
fail(`cannot read JSON: ${error instanceof Error ? error.message : String(error)}`)
|
|
211
|
+
process.exit()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!source?.canvas || !Number.isInteger(source.canvas.width) || source.canvas.width <= 0
|
|
215
|
+
|| !Number.isInteger(source.canvas.height) || source.canvas.height <= 0) {
|
|
216
|
+
fail('canvas.width and canvas.height must be positive integers')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (typeof source?.json === 'string') {
|
|
220
|
+
checkJsxString(source.json)
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
checkObjectProjection(source?.json)
|
|
224
|
+
checkObjectFlatNesting(source?.json)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!process.exitCode)
|
|
228
|
+
process.stdout.write('authoring-source valid\n')
|