@syncedco/flow 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/CODE_OF_CONDUCT.md +26 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/SECURITY.md +30 -0
- package/SUPPORT.md +32 -0
- package/TRADEMARKS.md +14 -0
- package/base.css +100 -0
- package/bin/synced-flow.mjs +4639 -0
- package/components.css +1392 -0
- package/defaults.css +26 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.js +3 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +67 -0
- package/docs/accessibility-css.md +133 -0
- package/docs/ai-usage.md +112 -0
- package/docs/api-contract.md +81 -0
- package/docs/base-styling.md +113 -0
- package/docs/build-a-site-walkthrough.md +122 -0
- package/docs/cli-reference.md +230 -0
- package/docs/config-reference.md +81 -0
- package/docs/css-optimisation.md +117 -0
- package/docs/migration-from-tailwind.md +60 -0
- package/docs/native-components.md +156 -0
- package/docs/patterns.md +32 -0
- package/docs/presets.md +60 -0
- package/docs/quick-start.md +252 -0
- package/docs/recipes.md +285 -0
- package/docs/release-readiness.md +63 -0
- package/docs/system-primitives.md +150 -0
- package/docs/tailwind-comparison.md +66 -0
- package/docs/tokens.md +79 -0
- package/docs/website-patterns.md +114 -0
- package/docs/why-synced-flow.md +99 -0
- package/docs/wordpress.md +66 -0
- package/examples/README.md +16 -0
- package/examples/astro/package.json +19 -0
- package/examples/astro/src/pages/index.astro +85 -0
- package/examples/astro/src/styles/synced-flow.css +2 -0
- package/examples/astro/src/styles/synced-flow.generated.css +206 -0
- package/examples/astro/synced-flow.config.mjs +9 -0
- package/examples/next/app/layout.tsx +14 -0
- package/examples/next/app/page.tsx +92 -0
- package/examples/next/app/synced-flow.css +2 -0
- package/examples/next/app/synced-flow.generated.css +205 -0
- package/examples/next/package.json +19 -0
- package/examples/next/synced-flow.config.mjs +9 -0
- package/examples/plain-html/index.html +384 -0
- package/examples/plain-html/package.json +15 -0
- package/examples/plain-html/synced-flow.config.mjs +9 -0
- package/examples/plain-html/synced-flow.css +2 -0
- package/examples/plain-html/synced-flow.generated.css +205 -0
- package/examples/templates/README.md +22 -0
- package/examples/templates/blog-index.html +41 -0
- package/examples/templates/coming-soon.html +27 -0
- package/examples/templates/portfolio-scroll.html +45 -0
- package/examples/templates/saas-dashboard.html +171 -0
- package/examples/templates/saas-landing.html +104 -0
- package/examples/vite/index.html +2 -0
- package/examples/vite/package.json +20 -0
- package/examples/vite/src/main.jsx +27 -0
- package/examples/vite/src/synced-flow.css +2 -0
- package/examples/vite/src/synced-flow.generated.css +205 -0
- package/examples/vite/synced-flow.config.mjs +9 -0
- package/examples/wordpress/assets/css/synced-flow.css +641 -0
- package/examples/wordpress/functions.php +13 -0
- package/examples/wordpress/package.json +15 -0
- package/examples/wordpress/parts/footer.html +12 -0
- package/examples/wordpress/parts/header.html +10 -0
- package/examples/wordpress/patterns/contact-cta.php +28 -0
- package/examples/wordpress/patterns/feature-grid.php +38 -0
- package/examples/wordpress/patterns/landing-hero.php +45 -0
- package/examples/wordpress/synced-flow.config.mjs +11 -0
- package/examples/wordpress/templates/front-page.html +11 -0
- package/examples/wordpress/templates/index.html +27 -0
- package/examples/wordpress/theme.json +19 -0
- package/layout.css +365 -0
- package/package.json +93 -0
- package/reset.css +13 -0
- package/skills/synced-flow/SKILL.md +151 -0
- package/src/config.ts +98 -0
- package/src/index.ts +75 -0
- package/src/presets.d.mts +21 -0
- package/src/presets.mjs +171 -0
- package/src/tokens.mjs +138 -0
- package/src/utility-tokens.mjs +47 -0
- package/styles.css +2313 -0
- package/tokens.css +198 -0
- package/utilities.css +255 -0
|
@@ -0,0 +1,4639 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, renameSync, watch } from 'node:fs'
|
|
5
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
|
+
|
|
8
|
+
import { themePresets, presetNames } from '../src/presets.mjs'
|
|
9
|
+
import { componentTokens, primitiveTokens, semanticTokens } from '../src/tokens.mjs'
|
|
10
|
+
import { fluidConfig } from '../src/utility-tokens.mjs'
|
|
11
|
+
|
|
12
|
+
const scriptFile = fileURLToPath(import.meta.url)
|
|
13
|
+
const scriptDir = fileURLToPath(new URL('.', import.meta.url))
|
|
14
|
+
const packageRoot = resolve(scriptDir, '..')
|
|
15
|
+
const args = process.argv.slice(2)
|
|
16
|
+
const command = args[0] && !args[0].startsWith('-') ? args.shift() : 'build'
|
|
17
|
+
|
|
18
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
19
|
+
printHelp()
|
|
20
|
+
process.exit(0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (command === 'init') {
|
|
24
|
+
await runInit()
|
|
25
|
+
process.exit(0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (command === 'agents') {
|
|
29
|
+
process.exit(await runAgentsCommand())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (command === 'skill') {
|
|
33
|
+
runSkill()
|
|
34
|
+
process.exit(0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (command === 'add') {
|
|
38
|
+
const addTarget = args.shift()
|
|
39
|
+
if (addTarget === 'defaults') {
|
|
40
|
+
runAddDefaults()
|
|
41
|
+
process.exit(0)
|
|
42
|
+
}
|
|
43
|
+
console.error(`Unknown add target "${addTarget ?? ''}". Use: synced-flow add defaults`)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (command === 'doctor' || command === 'validate') {
|
|
48
|
+
process.exit(await runDoctor())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (command === 'tokens') {
|
|
52
|
+
runTokens()
|
|
53
|
+
process.exit(0)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (command === 'catalog') {
|
|
57
|
+
runCatalog()
|
|
58
|
+
process.exit(0)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (command === 'suggest') {
|
|
62
|
+
runSuggest()
|
|
63
|
+
process.exit(0)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (command === 'pattern' || command === 'patterns') {
|
|
67
|
+
runPattern()
|
|
68
|
+
process.exit(0)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (command === 'recipe' || command === 'recipes') {
|
|
72
|
+
runRecipe()
|
|
73
|
+
process.exit(0)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (command === 'theme') {
|
|
77
|
+
process.exit(await runThemeCommand())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const projectCommands = new Set(['build', 'lint', 'watch'])
|
|
81
|
+
|
|
82
|
+
if (!projectCommands.has(command)) {
|
|
83
|
+
console.error(`Unknown command "${command}".`)
|
|
84
|
+
printHelp()
|
|
85
|
+
process.exit(1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
89
|
+
printHelp()
|
|
90
|
+
process.exit(0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cliCwd = readOption('cwd')
|
|
94
|
+
const configPath = resolveConfigPath(readOption('config'), resolve(cliCwd ?? process.cwd()))
|
|
95
|
+
const config = configPath ? await loadConfig(configPath) : {}
|
|
96
|
+
validateConfig(config, configPath ?? 'inline options')
|
|
97
|
+
const checkOnly = args.includes('--check')
|
|
98
|
+
const cwd = resolve(cliCwd ?? config.cwd ?? process.cwd())
|
|
99
|
+
const repoRoot = cwd
|
|
100
|
+
const outFile = resolve(cwd, readOption('out') ?? config.out ?? 'synced-flow.generated.css')
|
|
101
|
+
const cliScans = readOptions('scan')
|
|
102
|
+
const positionalSourceDirs = command === 'lint' ? readPositionals() : []
|
|
103
|
+
const sourceDirs = cliScans.length ? cliScans : positionalSourceDirs.length ? positionalSourceDirs : config.scan ?? (command === 'lint' ? ['.'] : [])
|
|
104
|
+
const includeCore = readBooleanOption('include-core') ?? config.includeCore ?? false
|
|
105
|
+
const includeDefaults = readBooleanOption('defaults') ?? config.includeDefaults ?? false
|
|
106
|
+
const responsiveVariants = readBooleanOption('responsive-variants') ?? config.responsiveVariants ?? false
|
|
107
|
+
const failOnUnsupported = readBooleanOption('fail-on-unsupported') ?? config.failOnUnsupported ?? true
|
|
108
|
+
const quiet = args.includes('--quiet') || config.quiet === true
|
|
109
|
+
const cliSafelist = readOptions('safelist')
|
|
110
|
+
const safelist = cliSafelist.length ? cliSafelist : config.safelist ?? []
|
|
111
|
+
|
|
112
|
+
const breakpoints = {
|
|
113
|
+
sm: '36rem',
|
|
114
|
+
md: '48rem',
|
|
115
|
+
lg: '64rem',
|
|
116
|
+
xl: '80rem',
|
|
117
|
+
'2xl': '96rem',
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!sourceDirs.length) {
|
|
121
|
+
console.error('No scan directories configured. Add scan to synced-flow.config.mjs or pass --scan app --scan components.')
|
|
122
|
+
process.exit(1)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const missingSourceDirs = sourceDirs.filter((dir) => !existsSync(resolve(repoRoot, dir)))
|
|
126
|
+
if (missingSourceDirs.length && !quiet) {
|
|
127
|
+
console.warn(`Scan directories not found: ${missingSourceDirs.join(', ')}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (command === 'watch') {
|
|
131
|
+
await runWatch()
|
|
132
|
+
process.exit(0)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readOption(name, fallback) {
|
|
136
|
+
const prefix = `--${name}=`
|
|
137
|
+
const inline = args.find((arg) => arg.startsWith(prefix))
|
|
138
|
+
if (inline) return inline.slice(prefix.length)
|
|
139
|
+
|
|
140
|
+
const index = args.indexOf(`--${name}`)
|
|
141
|
+
if (index !== -1 && args[index + 1] && !args[index + 1].startsWith('--')) return args[index + 1]
|
|
142
|
+
|
|
143
|
+
return fallback
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readOptions(name) {
|
|
147
|
+
const values = []
|
|
148
|
+
const prefix = `--${name}=`
|
|
149
|
+
|
|
150
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
151
|
+
const arg = args[index]
|
|
152
|
+
if (arg.startsWith(prefix)) {
|
|
153
|
+
values.push(...splitOptionList(arg.slice(prefix.length)))
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (arg === `--${name}` && args[index + 1] && !args[index + 1].startsWith('--')) {
|
|
157
|
+
values.push(...splitOptionList(args[index + 1]))
|
|
158
|
+
index += 1
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return values
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readPositionals() {
|
|
166
|
+
const valueOptions = new Set(['--config', '--cwd', '--preset', '--theme', '--scan', '--out', '--safelist', '--file', '--css', '--from', '--limit', '--framework', '--section', '--target', '--agents', '--preset-base'])
|
|
167
|
+
const values = []
|
|
168
|
+
|
|
169
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
170
|
+
const arg = args[index]
|
|
171
|
+
if (arg.startsWith('--')) {
|
|
172
|
+
if (valueOptions.has(arg) && args[index + 1] && !args[index + 1].startsWith('--')) index += 1
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
values.push(arg)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return values
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function splitOptionList(value) {
|
|
182
|
+
return value.split(/[,\s]+/).filter(Boolean)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function runWatch() {
|
|
186
|
+
const buildArgs = ['build', ...args.filter((arg) => arg !== '--watch')]
|
|
187
|
+
let timer = null
|
|
188
|
+
let running = false
|
|
189
|
+
|
|
190
|
+
function build(reason) {
|
|
191
|
+
if (running) return
|
|
192
|
+
running = true
|
|
193
|
+
const label = reason ? ` after ${reason}` : ''
|
|
194
|
+
console.log(`synced-flow build${label}`)
|
|
195
|
+
const result = spawnSync(process.execPath, [scriptFile, ...buildArgs], {
|
|
196
|
+
cwd: process.cwd(),
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
stdio: 'inherit',
|
|
199
|
+
})
|
|
200
|
+
running = false
|
|
201
|
+
if (result.status === 0) console.log('pass build complete. Watching for changes...')
|
|
202
|
+
else console.log('warn build failed. Fix the issue and save again.')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function schedule(reason) {
|
|
206
|
+
clearTimeout(timer)
|
|
207
|
+
timer = setTimeout(() => build(reason), 120)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
build()
|
|
211
|
+
|
|
212
|
+
const watched = new Set()
|
|
213
|
+
for (const dir of sourceDirs) {
|
|
214
|
+
const fullDir = resolve(repoRoot, dir)
|
|
215
|
+
if (!existsSync(fullDir)) continue
|
|
216
|
+
for (const file of listProjectFiles(fullDir)) {
|
|
217
|
+
const parent = dirname(file)
|
|
218
|
+
if (watched.has(parent)) continue
|
|
219
|
+
watched.add(parent)
|
|
220
|
+
watch(parent, { persistent: true }, (_event, filename) => {
|
|
221
|
+
if (filename && !/\.(tsx|ts|jsx|js|astro|html|php|phtml|twig|mdx|vue|svelte|css)$/.test(String(filename))) return
|
|
222
|
+
schedule(filename ? String(filename) : relative(repoRoot, parent))
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
console.log(`Watching ${watched.size} directories. Press Ctrl+C to stop.`)
|
|
228
|
+
await new Promise(() => {})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeThemeName(value) {
|
|
232
|
+
const aliases = {
|
|
233
|
+
'dark-app': 'darkApp',
|
|
234
|
+
'neutral-saas': 'neutralSaas',
|
|
235
|
+
}
|
|
236
|
+
return aliases[value] ?? value
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function kebabThemeName(value) {
|
|
240
|
+
return value.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readBooleanOption(name) {
|
|
244
|
+
if (args.includes(`--${name}`)) return true
|
|
245
|
+
if (args.includes(`--no-${name}`)) return false
|
|
246
|
+
return undefined
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resolveConfigPath(value, baseCwd) {
|
|
250
|
+
if (value) {
|
|
251
|
+
const explicit = resolve(baseCwd, value)
|
|
252
|
+
if (!existsSync(explicit)) {
|
|
253
|
+
console.error(`Config file not found: ${relative(baseCwd, explicit)}`)
|
|
254
|
+
process.exit(1)
|
|
255
|
+
}
|
|
256
|
+
return explicit
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const name of ['synced-flow.config.mjs', 'synced-flow.config.js', 'synced-flow.config.cjs']) {
|
|
260
|
+
const candidate = resolve(baseCwd, name)
|
|
261
|
+
if (existsSync(candidate)) return candidate
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function loadConfig(file) {
|
|
268
|
+
const module = await import(pathToFileURL(file).href)
|
|
269
|
+
const loaded = module.default ?? module.config ?? {}
|
|
270
|
+
|
|
271
|
+
if (!loaded || typeof loaded !== 'object' || Array.isArray(loaded)) {
|
|
272
|
+
console.error(`Config file must export an object: ${relative(process.cwd(), file)}`)
|
|
273
|
+
process.exit(1)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return loaded
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function validateConfig(config, label) {
|
|
280
|
+
const errors = []
|
|
281
|
+
|
|
282
|
+
assertOptionalString(config, 'cwd', errors)
|
|
283
|
+
assertOptionalString(config, 'out', errors)
|
|
284
|
+
assertOptionalBoolean(config, 'includeCore', errors)
|
|
285
|
+
assertOptionalBoolean(config, 'includeDefaults', errors)
|
|
286
|
+
assertOptionalBoolean(config, 'responsiveVariants', errors)
|
|
287
|
+
assertOptionalBoolean(config, 'failOnUnsupported', errors)
|
|
288
|
+
assertOptionalBoolean(config, 'quiet', errors)
|
|
289
|
+
assertOptionalStringArray(config, 'scan', errors)
|
|
290
|
+
assertOptionalStringArray(config, 'safelist', errors)
|
|
291
|
+
|
|
292
|
+
if (config.theme !== undefined) validateTheme(config.theme, errors)
|
|
293
|
+
|
|
294
|
+
if (errors.length) {
|
|
295
|
+
console.error(`Invalid Synced Flow config in ${label}:`)
|
|
296
|
+
for (const error of errors) console.error(` - ${error}`)
|
|
297
|
+
process.exit(1)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validateTheme(theme, errors) {
|
|
302
|
+
if (!isPlainObject(theme)) {
|
|
303
|
+
errors.push('theme must be an object.')
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
assertOptionalStringMap(theme, 'fonts', errors)
|
|
308
|
+
assertOptionalStringMap(theme, 'colours', errors)
|
|
309
|
+
assertOptionalStringMap(theme, 'darkColours', errors)
|
|
310
|
+
assertOptionalStringMap(theme, 'radii', errors)
|
|
311
|
+
|
|
312
|
+
if (theme.layout !== undefined) {
|
|
313
|
+
if (!isPlainObject(theme.layout)) {
|
|
314
|
+
errors.push('theme.layout must be an object.')
|
|
315
|
+
} else {
|
|
316
|
+
assertOptionalString(theme.layout, 'containerMax', errors, 'theme.layout.containerMax')
|
|
317
|
+
assertOptionalString(theme.layout, 'gutter', errors, 'theme.layout.gutter')
|
|
318
|
+
if (theme.layout.columns !== undefined && (!Number.isFinite(theme.layout.columns) || theme.layout.columns <= 0)) {
|
|
319
|
+
errors.push('theme.layout.columns must be a positive number.')
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (theme.components !== undefined) {
|
|
325
|
+
if (!isPlainObject(theme.components)) {
|
|
326
|
+
errors.push('theme.components must be an object.')
|
|
327
|
+
} else {
|
|
328
|
+
for (const [componentName, componentConfig] of Object.entries(theme.components)) {
|
|
329
|
+
if (!isPlainObject(componentConfig)) {
|
|
330
|
+
errors.push(`theme.components.${componentName} must be an object.`)
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
for (const [tokenName, value] of Object.entries(componentConfig)) {
|
|
334
|
+
if (typeof value !== 'string') errors.push(`theme.components.${componentName}.${tokenName} must be a string.`)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function assertOptionalString(object, key, errors, label = key) {
|
|
342
|
+
if (object[key] !== undefined && typeof object[key] !== 'string') errors.push(`${label} must be a string.`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function assertOptionalBoolean(object, key, errors) {
|
|
346
|
+
if (object[key] !== undefined && typeof object[key] !== 'boolean') errors.push(`${key} must be a boolean.`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function assertOptionalStringArray(object, key, errors) {
|
|
350
|
+
if (object[key] === undefined) return
|
|
351
|
+
if (!Array.isArray(object[key])) {
|
|
352
|
+
errors.push(`${key} must be an array of strings.`)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
for (const value of object[key]) {
|
|
356
|
+
if (typeof value !== 'string' || !value.trim()) errors.push(`${key} must contain only non-empty strings.`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function assertOptionalStringMap(object, key, errors) {
|
|
361
|
+
if (object[key] === undefined) return
|
|
362
|
+
if (!isPlainObject(object[key])) {
|
|
363
|
+
errors.push(`theme.${key} must be an object of string values.`)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
for (const [name, value] of Object.entries(object[key])) {
|
|
367
|
+
if (typeof value !== 'string' || !value.trim()) errors.push(`theme.${key}.${name} must be a non-empty string.`)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isPlainObject(value) {
|
|
372
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function printHelp() {
|
|
376
|
+
console.log(`Usage:
|
|
377
|
+
synced-flow init [options]
|
|
378
|
+
synced-flow agents install [options]
|
|
379
|
+
synced-flow agents status [options]
|
|
380
|
+
synced-flow skill [options]
|
|
381
|
+
synced-flow add defaults [options]
|
|
382
|
+
synced-flow build [options]
|
|
383
|
+
synced-flow doctor [options]
|
|
384
|
+
synced-flow validate [options]
|
|
385
|
+
synced-flow tokens [options]
|
|
386
|
+
synced-flow catalog [options]
|
|
387
|
+
synced-flow suggest "<site or section brief>" [options]
|
|
388
|
+
synced-flow pattern [id] [options]
|
|
389
|
+
synced-flow recipe [id] [options]
|
|
390
|
+
synced-flow lint [options]
|
|
391
|
+
synced-flow watch [options]
|
|
392
|
+
synced-flow theme init --from <brief.md> [options]
|
|
393
|
+
synced-flow theme validate [options]
|
|
394
|
+
|
|
395
|
+
Options:
|
|
396
|
+
--config <file> Use a specific config file.
|
|
397
|
+
--cwd <dir> Resolve scans and output from a directory.
|
|
398
|
+
--preset <name> init preset: next, vite, react, astro, wordpress, plain.
|
|
399
|
+
--agents <target> init and install project AI guidance: universal, cursor, codex, claude, copilot, windsurf, gemini, aider, all.
|
|
400
|
+
--target <name> agents install target: universal, cursor, codex, claude, copilot, windsurf, gemini, aider, all.
|
|
401
|
+
--theme <name> init theme: synced, neutral-saas, editorial, dark-app.
|
|
402
|
+
--scan <dir> Add a source directory to scan for classes.
|
|
403
|
+
--out <file> Generated CSS output path.
|
|
404
|
+
--safelist <classes> Always generate space/comma-separated class names.
|
|
405
|
+
--json Print token information as JSON.
|
|
406
|
+
--check Fail if the generated CSS is out of date.
|
|
407
|
+
--include-core Include reset, base, layout, and component CSS in output.
|
|
408
|
+
--no-include-core Only generate project tokens and utility CSS.
|
|
409
|
+
--defaults Include defaults.css opinionated site/UI defaults.
|
|
410
|
+
--no-defaults Keep defaults.css out of init/generated core output.
|
|
411
|
+
--file <file> CSS file to update for "synced-flow add defaults".
|
|
412
|
+
--responsive-variants Enable sm:/md:/lg:/xl: compatibility variants.
|
|
413
|
+
--no-responsive-variants Treat responsive variants as unsupported.
|
|
414
|
+
--fail-on-unsupported Fail when class tokens cannot be generated.
|
|
415
|
+
--no-fail-on-unsupported Warn but do not fail on unsupported class tokens.
|
|
416
|
+
--no-scripts init without adding package.json scripts.
|
|
417
|
+
--force overwrite init-managed files.
|
|
418
|
+
--quiet Suppress non-critical warnings.
|
|
419
|
+
--from <file> Read a theme brief for "theme init".
|
|
420
|
+
--preset-base <name> Base "theme init" output on synced, neutral-saas, editorial, or dark-app.
|
|
421
|
+
--limit <number> Limit results for "suggest".
|
|
422
|
+
--markup Include markup snippets in text recipe output.
|
|
423
|
+
--dry-run Print planned agent/scaffold writes without changing files.
|
|
424
|
+
--fix Accept lint auto-fix mode; current composition rules print guided fixes.
|
|
425
|
+
|
|
426
|
+
Config:
|
|
427
|
+
Create synced-flow.config.mjs and export defineConfig({ scan, out }).`)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function runInit() {
|
|
431
|
+
const targetCwd = resolve(readOption('cwd', process.cwd()))
|
|
432
|
+
const preset = readOption('preset') ?? detectPreset(targetCwd)
|
|
433
|
+
const themeName = normalizeThemeName(readOption('theme', 'synced'))
|
|
434
|
+
if (!themePresets[themeName]) {
|
|
435
|
+
console.error(`Unknown theme "${readOption('theme')}". Use one of: ${presetNames.map(kebabThemeName).join(', ')}.`)
|
|
436
|
+
process.exit(1)
|
|
437
|
+
}
|
|
438
|
+
const force = args.includes('--force')
|
|
439
|
+
const noScripts = args.includes('--no-scripts')
|
|
440
|
+
const responsive = readBooleanOption('responsive-variants') ?? preset === 'legacy'
|
|
441
|
+
const includeCore = readBooleanOption('include-core') ?? preset === 'wordpress'
|
|
442
|
+
const includeDefaults = readBooleanOption('defaults') ?? defaultIncludeDefaults(preset)
|
|
443
|
+
const scanDirs = readOptions('scan').length ? readOptions('scan') : defaultScanDirs(targetCwd, preset)
|
|
444
|
+
const outPath = readOption('out') ?? defaultOutputPath(targetCwd, preset)
|
|
445
|
+
const stylePath = includeCore ? outPath : defaultStyleEntryPath(targetCwd, preset, outPath)
|
|
446
|
+
const configFile = resolve(targetCwd, readOption('config', 'synced-flow.config.mjs'))
|
|
447
|
+
const generatedFile = resolve(targetCwd, outPath)
|
|
448
|
+
const styleFile = resolve(targetCwd, stylePath)
|
|
449
|
+
const singleCssOutput = generatedFile === styleFile
|
|
450
|
+
const safelistValues = readOptions('safelist')
|
|
451
|
+
const agentsTarget = readOption('agents')
|
|
452
|
+
|
|
453
|
+
writeProjectFile(
|
|
454
|
+
configFile,
|
|
455
|
+
`import { defineConfig } from '@syncedco/flow/config'
|
|
456
|
+
import { themePresets } from '@syncedco/flow/presets'
|
|
457
|
+
|
|
458
|
+
export default defineConfig({
|
|
459
|
+
scan: ${formatStringArray(scanDirs)},
|
|
460
|
+
out: '${outPath}',
|
|
461
|
+
responsiveVariants: ${responsive},
|
|
462
|
+
${includeCore ? ' includeCore: true,\n' : ''}${includeCore && includeDefaults ? ' includeDefaults: true,\n' : ''} theme: themePresets.${themeName},
|
|
463
|
+
${safelistValues.length ? ` safelist: ${formatStringArray(safelistValues)},\n` : ''}})
|
|
464
|
+
`,
|
|
465
|
+
force
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if (singleCssOutput) {
|
|
469
|
+
writeProjectFile(
|
|
470
|
+
generatedFile,
|
|
471
|
+
`/* Generated by @syncedco/flow. Run synced-flow build to refresh this file. */
|
|
472
|
+
`,
|
|
473
|
+
force
|
|
474
|
+
)
|
|
475
|
+
} else {
|
|
476
|
+
writeProjectFile(
|
|
477
|
+
styleFile,
|
|
478
|
+
`@import "@syncedco/flow/styles.css";
|
|
479
|
+
${includeDefaults ? '@import "@syncedco/flow/defaults.css";\n' : ''}@import "./${relative(dirname(styleFile), generatedFile).replace(/\\/g, '/')}";
|
|
480
|
+
|
|
481
|
+
/* Prefer synced-flow.config.mjs theme overrides for reusable project tokens. */
|
|
482
|
+
`,
|
|
483
|
+
force
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
writeProjectFile(
|
|
487
|
+
generatedFile,
|
|
488
|
+
`/* Generated by @syncedco/flow. Run synced-flow build to refresh this file. */
|
|
489
|
+
`,
|
|
490
|
+
force
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!noScripts) addPackageScripts(targetCwd)
|
|
495
|
+
if (agentsTarget !== undefined || args.includes('--agents')) {
|
|
496
|
+
await installAgentGuidance({
|
|
497
|
+
targetCwd,
|
|
498
|
+
target: agentsTarget || 'universal',
|
|
499
|
+
force,
|
|
500
|
+
dryRun: args.includes('--dry-run'),
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
console.log(`Synced Flow initialised for ${preset} with ${kebabThemeName(themeName)} theme.`)
|
|
505
|
+
console.log(`Config: ${relative(targetCwd, configFile)}`)
|
|
506
|
+
if (singleCssOutput) {
|
|
507
|
+
console.log(`CSS output: ${relative(targetCwd, generatedFile)}`)
|
|
508
|
+
} else {
|
|
509
|
+
console.log(`CSS entry: ${relative(targetCwd, styleFile)}`)
|
|
510
|
+
console.log(`Generated CSS: ${relative(targetCwd, generatedFile)}`)
|
|
511
|
+
}
|
|
512
|
+
console.log('')
|
|
513
|
+
console.log('Next steps:')
|
|
514
|
+
if (preset === 'wordpress') {
|
|
515
|
+
console.log(` 1. Enqueue ${relative(targetCwd, generatedFile)} from your theme or plugin.`)
|
|
516
|
+
} else {
|
|
517
|
+
console.log(` 1. Import ${relative(targetCwd, styleFile)} from your app entry/layout.`)
|
|
518
|
+
}
|
|
519
|
+
console.log(' 2. Run pnpm flow:build')
|
|
520
|
+
console.log(' 3. Run pnpm flow:doctor')
|
|
521
|
+
if (includeDefaults) {
|
|
522
|
+
console.log('Note: defaults.css removes raw link underlines and list markers for common site/UI surfaces. Use sf-link, sf-list-disc, or sf-prose when content semantics need them.')
|
|
523
|
+
} else {
|
|
524
|
+
console.log('Note: base CSS keeps links visibly underlined and list markers intact. Add @syncedco/flow/defaults.css or run synced-flow add defaults for site/UI defaults.')
|
|
525
|
+
}
|
|
526
|
+
console.log('AI setup: run synced-flow agents install to add project-level agent guidance.')
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function runAgentsCommand() {
|
|
530
|
+
const subcommand = args.shift() ?? 'status'
|
|
531
|
+
if (subcommand === 'install') {
|
|
532
|
+
await installAgentGuidance({
|
|
533
|
+
targetCwd: resolve(readOption('cwd', process.cwd())),
|
|
534
|
+
target: readOption('target', 'universal'),
|
|
535
|
+
force: args.includes('--force'),
|
|
536
|
+
dryRun: args.includes('--dry-run'),
|
|
537
|
+
})
|
|
538
|
+
return 0
|
|
539
|
+
}
|
|
540
|
+
if (subcommand === 'status') {
|
|
541
|
+
printAgentsStatus(resolve(readOption('cwd', process.cwd())))
|
|
542
|
+
return 0
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
console.error('Unknown agents command. Use: synced-flow agents install or synced-flow agents status')
|
|
546
|
+
return 1
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function runSkill() {
|
|
550
|
+
const skillPath = join(packageRoot, 'skills/synced-flow/SKILL.md')
|
|
551
|
+
console.log('Synced Flow AI skill')
|
|
552
|
+
console.log('')
|
|
553
|
+
console.log(`Skill file: ${relative(process.cwd(), skillPath)}`)
|
|
554
|
+
console.log('Use it to guide AI agents toward Synced Flow tokens, classes, recipes, patterns, lint, and doctor checks.')
|
|
555
|
+
console.log('')
|
|
556
|
+
console.log('Project setup:')
|
|
557
|
+
console.log(' synced-flow agents install')
|
|
558
|
+
console.log(' synced-flow agents install --target all')
|
|
559
|
+
console.log('')
|
|
560
|
+
console.log('Core agent commands:')
|
|
561
|
+
console.log(' synced-flow catalog --json')
|
|
562
|
+
console.log(' synced-flow pattern --list')
|
|
563
|
+
console.log(' synced-flow suggest "site brief" --scaffold --dry-run')
|
|
564
|
+
console.log(' synced-flow lint --json')
|
|
565
|
+
console.log(' synced-flow doctor')
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function installAgentGuidance({ targetCwd, target = 'universal', force = false, dryRun = false }) {
|
|
569
|
+
const targets = expandAgentTargets(target)
|
|
570
|
+
if (!targets.length) {
|
|
571
|
+
console.error(`Unknown agents target "${target}". Use universal, cursor, codex, claude, copilot, windsurf, gemini, aider, or all.`)
|
|
572
|
+
process.exitCode = 1
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const writes = []
|
|
577
|
+
for (const targetName of targets) writes.push(...agentWritesForTarget(targetCwd, targetName))
|
|
578
|
+
|
|
579
|
+
const seen = new Set()
|
|
580
|
+
for (const write of writes) {
|
|
581
|
+
const key = `${write.file}:${write.mode ?? 'file'}`
|
|
582
|
+
if (seen.has(key)) continue
|
|
583
|
+
seen.add(key)
|
|
584
|
+
applyAgentWrite(targetCwd, write, { force, dryRun })
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (dryRun) console.log('dry-run no files were changed.')
|
|
588
|
+
else console.log('Synced Flow agent guidance is ready.')
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function expandAgentTargets(target) {
|
|
592
|
+
const requested = splitOptionList(target || 'universal')
|
|
593
|
+
const all = ['universal', 'cursor', 'codex', 'claude', 'copilot', 'windsurf', 'gemini', 'aider']
|
|
594
|
+
const expanded = requested.flatMap((entry) => (entry === 'all' ? all : [entry]))
|
|
595
|
+
return expanded.filter((entry, index) => all.includes(entry) && expanded.indexOf(entry) === index)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function agentWritesForTarget(targetCwd, target) {
|
|
599
|
+
const skill = readFileSync(join(packageRoot, 'skills/synced-flow/SKILL.md'), 'utf8')
|
|
600
|
+
const commonRules = syncedFluidAgentRules()
|
|
601
|
+
|
|
602
|
+
if (target === 'universal' || target === 'codex') {
|
|
603
|
+
return [
|
|
604
|
+
{
|
|
605
|
+
file: resolve(targetCwd, 'AGENTS.md'),
|
|
606
|
+
mode: 'section',
|
|
607
|
+
title: 'Synced Flow',
|
|
608
|
+
contents: commonRules,
|
|
609
|
+
},
|
|
610
|
+
]
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (target === 'cursor') {
|
|
614
|
+
return [
|
|
615
|
+
{
|
|
616
|
+
file: resolve(targetCwd, '.cursor/rules/synced-flow.mdc'),
|
|
617
|
+
contents: `---
|
|
618
|
+
description: Use Synced Flow CSS primitives, recipes, patterns, and guardrails when building UI in this project.
|
|
619
|
+
alwaysApply: false
|
|
620
|
+
---
|
|
621
|
+
|
|
622
|
+
${commonRules}
|
|
623
|
+
`,
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
file: resolve(targetCwd, 'AGENTS.md'),
|
|
627
|
+
mode: 'section',
|
|
628
|
+
title: 'Synced Flow',
|
|
629
|
+
contents: commonRules,
|
|
630
|
+
},
|
|
631
|
+
]
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (target === 'claude') {
|
|
635
|
+
return [
|
|
636
|
+
{
|
|
637
|
+
file: resolve(targetCwd, '.claude/skills/synced-flow/SKILL.md'),
|
|
638
|
+
contents: skill,
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
file: resolve(targetCwd, 'CLAUDE.md'),
|
|
642
|
+
mode: 'section',
|
|
643
|
+
title: 'Synced Flow',
|
|
644
|
+
contents: commonRules,
|
|
645
|
+
},
|
|
646
|
+
]
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (target === 'copilot') {
|
|
650
|
+
return [
|
|
651
|
+
{
|
|
652
|
+
file: resolve(targetCwd, '.github/copilot-instructions.md'),
|
|
653
|
+
mode: 'section',
|
|
654
|
+
title: 'Synced Flow',
|
|
655
|
+
contents: commonRules,
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
file: resolve(targetCwd, '.github/instructions/synced-flow.instructions.md'),
|
|
659
|
+
contents: `---
|
|
660
|
+
applyTo: "**/*.{html,css,js,jsx,ts,tsx,astro,vue,svelte,php,mdx}"
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
${commonRules}
|
|
664
|
+
`,
|
|
665
|
+
},
|
|
666
|
+
]
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (target === 'windsurf') {
|
|
670
|
+
return [
|
|
671
|
+
{
|
|
672
|
+
file: resolve(targetCwd, '.windsurf/rules/synced-flow.md'),
|
|
673
|
+
contents: `---
|
|
674
|
+
trigger: model_decision
|
|
675
|
+
description: Use Synced Flow CSS primitives, recipes, patterns, and guardrails for UI work.
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
${commonRules}
|
|
679
|
+
`,
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
file: resolve(targetCwd, 'AGENTS.md'),
|
|
683
|
+
mode: 'section',
|
|
684
|
+
title: 'Synced Flow',
|
|
685
|
+
contents: commonRules,
|
|
686
|
+
},
|
|
687
|
+
]
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (target === 'gemini') {
|
|
691
|
+
return [
|
|
692
|
+
{
|
|
693
|
+
file: resolve(targetCwd, 'GEMINI.md'),
|
|
694
|
+
mode: 'section',
|
|
695
|
+
title: 'Synced Flow',
|
|
696
|
+
contents: commonRules,
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
file: resolve(targetCwd, 'AGENTS.md'),
|
|
700
|
+
mode: 'section',
|
|
701
|
+
title: 'Synced Flow',
|
|
702
|
+
contents: commonRules,
|
|
703
|
+
},
|
|
704
|
+
]
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (target === 'aider') {
|
|
708
|
+
return [
|
|
709
|
+
{
|
|
710
|
+
file: resolve(targetCwd, 'AGENTS.md'),
|
|
711
|
+
mode: 'section',
|
|
712
|
+
title: 'Synced Flow',
|
|
713
|
+
contents: commonRules,
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
file: resolve(targetCwd, '.aider.conf.yml'),
|
|
717
|
+
contents: `# Read project-level Synced Flow guidance.
|
|
718
|
+
read:
|
|
719
|
+
- AGENTS.md`,
|
|
720
|
+
},
|
|
721
|
+
]
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return []
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function syncedFluidAgentRules() {
|
|
728
|
+
return `Read the Synced Flow skill before generating UI:
|
|
729
|
+
- Packaged skill: node_modules/@syncedco/flow/skills/synced-flow/SKILL.md
|
|
730
|
+
- Prefer sf-* primitives, recipes, and patterns before custom CSS.
|
|
731
|
+
- Do not use Tailwind utilities as the design system surface.
|
|
732
|
+
- Keep class names complete in source; do not build dynamic fragments such as sf-\${...} or text-\${...}.
|
|
733
|
+
- Run synced-flow catalog --json before choosing recipes or classes.
|
|
734
|
+
- Use synced-flow pattern --list and synced-flow pattern <id> --markup for interaction markup.
|
|
735
|
+
- Run synced-flow lint --json and synced-flow doctor before finishing.
|
|
736
|
+
- Put brand tokens in synced-flow.config.mjs, not scattered --sf-* overrides.`
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function applyAgentWrite(targetCwd, write, { force, dryRun }) {
|
|
740
|
+
const rel = relative(targetCwd, write.file)
|
|
741
|
+
const existed = existsSync(write.file)
|
|
742
|
+
const next = write.mode === 'section' ? upsertManagedSection(write.file, write.title, write.contents, force, dryRun) : write.contents
|
|
743
|
+
|
|
744
|
+
if (write.mode === 'section') {
|
|
745
|
+
if (next === null) {
|
|
746
|
+
console.log(`skip ${rel} already has Synced Flow guidance. Use --force to refresh.`)
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
if (dryRun) {
|
|
750
|
+
console.log(`would ${existed ? 'update' : 'write'} ${rel}`)
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
mkdirSync(dirname(write.file), { recursive: true })
|
|
754
|
+
writeFileSync(write.file, next)
|
|
755
|
+
console.log(`${existed ? 'update' : 'write'} ${rel}`)
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (existsSync(write.file) && !force) {
|
|
760
|
+
console.log(`skip ${rel} already exists. Use --force to overwrite.`)
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
if (dryRun) {
|
|
764
|
+
console.log(`would write ${rel}`)
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
mkdirSync(dirname(write.file), { recursive: true })
|
|
768
|
+
writeFileSync(write.file, next)
|
|
769
|
+
console.log(`write ${rel}`)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function upsertManagedSection(file, title, contents, force, dryRun) {
|
|
773
|
+
const start = `<!-- synced-flow:${title}:start -->`
|
|
774
|
+
const end = `<!-- synced-flow:${title}:end -->`
|
|
775
|
+
const section = `${start}
|
|
776
|
+
## ${title}
|
|
777
|
+
|
|
778
|
+
${contents}
|
|
779
|
+
${end}`
|
|
780
|
+
const current = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
781
|
+
|
|
782
|
+
if (current.includes(start) && current.includes(end)) {
|
|
783
|
+
if (!force) return null
|
|
784
|
+
return current.replace(new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`), section)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${section}\n`
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function printAgentsStatus(targetCwd) {
|
|
791
|
+
const entries = [
|
|
792
|
+
['universal', 'AGENTS.md', hasManagedSyncedFlowSection(resolve(targetCwd, 'AGENTS.md'))],
|
|
793
|
+
['cursor', '.cursor/rules/synced-flow.mdc', existsSync(resolve(targetCwd, '.cursor/rules/synced-flow.mdc'))],
|
|
794
|
+
['claude', '.claude/skills/synced-flow/SKILL.md', existsSync(resolve(targetCwd, '.claude/skills/synced-flow/SKILL.md'))],
|
|
795
|
+
['copilot', '.github/copilot-instructions.md', hasManagedSyncedFlowSection(resolve(targetCwd, '.github/copilot-instructions.md'))],
|
|
796
|
+
['windsurf', '.windsurf/rules/synced-flow.md', existsSync(resolve(targetCwd, '.windsurf/rules/synced-flow.md'))],
|
|
797
|
+
['gemini', 'GEMINI.md', hasManagedSyncedFlowSection(resolve(targetCwd, 'GEMINI.md'))],
|
|
798
|
+
['aider', '.aider.conf.yml', existsSync(resolve(targetCwd, '.aider.conf.yml'))],
|
|
799
|
+
]
|
|
800
|
+
|
|
801
|
+
console.log('Synced Flow agent setup')
|
|
802
|
+
for (const [name, path, ok] of entries) console.log(`${ok ? 'pass' : 'warn'} ${name}: ${path}${ok ? '' : ' missing'}`)
|
|
803
|
+
if (!entries.some(([, , ok]) => ok)) console.log('Run: synced-flow agents install')
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function hasManagedSyncedFlowSection(file) {
|
|
807
|
+
return existsSync(file) && readFileSync(file, 'utf8').includes('<!-- synced-flow:Synced Flow:start -->')
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function escapeRegExp(value) {
|
|
811
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function runAddDefaults() {
|
|
815
|
+
const targetCwd = resolve(readOption('cwd', process.cwd()))
|
|
816
|
+
const cssPath = readOption('file') ?? readOption('css') ?? findStyleEntry(targetCwd)
|
|
817
|
+
|
|
818
|
+
if (!cssPath) {
|
|
819
|
+
console.error('Could not find a CSS entry. Pass --file <path>, for example: synced-flow add defaults --file src/synced-flow.css')
|
|
820
|
+
process.exit(1)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const cssFile = resolve(targetCwd, cssPath)
|
|
824
|
+
if (!existsSync(cssFile)) {
|
|
825
|
+
console.error(`CSS file not found: ${relative(targetCwd, cssFile)}`)
|
|
826
|
+
process.exit(1)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const css = readFileSync(cssFile, 'utf8')
|
|
830
|
+
if (css.includes('@syncedco/flow/defaults.css')) {
|
|
831
|
+
console.log(`skip ${relative(targetCwd, cssFile)} already imports @syncedco/flow/defaults.css.`)
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const defaultsImport = '@import "@syncedco/flow/defaults.css";'
|
|
836
|
+
let nextCss
|
|
837
|
+
|
|
838
|
+
if (css.includes('@import "@syncedco/flow/styles.css";')) {
|
|
839
|
+
nextCss = css.replace('@import "@syncedco/flow/styles.css";', `@import "@syncedco/flow/styles.css";\n${defaultsImport}`)
|
|
840
|
+
} else if (css.includes('@import "@syncedco/flow/base.css";')) {
|
|
841
|
+
nextCss = css.replace('@import "@syncedco/flow/base.css";', `@import "@syncedco/flow/base.css";\n${defaultsImport}`)
|
|
842
|
+
} else {
|
|
843
|
+
nextCss = `${defaultsImport}\n${css}`
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
writeFileSync(cssFile, nextCss)
|
|
847
|
+
console.log(`update ${relative(targetCwd, cssFile)} with @syncedco/flow/defaults.css`)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function runDoctor() {
|
|
851
|
+
const targetCwd = resolve(readOption('cwd', process.cwd()))
|
|
852
|
+
const messages = []
|
|
853
|
+
let failed = false
|
|
854
|
+
let loadedConfig = null
|
|
855
|
+
const packageSource = isSyncedFlowSource(targetCwd)
|
|
856
|
+
|
|
857
|
+
function pass(message) {
|
|
858
|
+
messages.push(`pass ${message}`)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function warn(message) {
|
|
862
|
+
messages.push(`warn ${message}`)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function fail(message) {
|
|
866
|
+
failed = true
|
|
867
|
+
messages.push(`fail ${message}`)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const packageFile = resolve(targetCwd, 'package.json')
|
|
871
|
+
if (!existsSync(packageFile)) {
|
|
872
|
+
warn('package.json not found; skipping dependency and script checks.')
|
|
873
|
+
} else {
|
|
874
|
+
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
|
|
875
|
+
if (packageSource) {
|
|
876
|
+
pass('Running in the @syncedco/flow source package.')
|
|
877
|
+
} else {
|
|
878
|
+
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
879
|
+
if (allDeps['@syncedco/flow']) pass('@syncedco/flow is installed.')
|
|
880
|
+
else warn('@syncedco/flow is not listed in package.json.')
|
|
881
|
+
|
|
882
|
+
const scripts = pkg.scripts ?? {}
|
|
883
|
+
if (scripts['flow:build'] === 'synced-flow build') pass('flow:build script is configured.')
|
|
884
|
+
else warn('Add "flow:build": "synced-flow build" to package.json.')
|
|
885
|
+
|
|
886
|
+
if (scripts['flow:check'] === 'synced-flow build --check') pass('flow:check script is configured.')
|
|
887
|
+
else warn('Add "flow:check": "synced-flow build --check" to package.json.')
|
|
888
|
+
|
|
889
|
+
if (scripts['flow:doctor'] === 'synced-flow doctor') pass('flow:doctor script is configured.')
|
|
890
|
+
else warn('Add "flow:doctor": "synced-flow doctor" to package.json.')
|
|
891
|
+
|
|
892
|
+
if (scripts['flow:lint'] === 'synced-flow lint') pass('flow:lint script is configured.')
|
|
893
|
+
else warn('Add "flow:lint": "synced-flow lint" to package.json so class mistakes are caught before handoff.')
|
|
894
|
+
|
|
895
|
+
if (scripts['flow:watch'] === 'synced-flow watch') pass('flow:watch script is configured.')
|
|
896
|
+
else warn('Add "flow:watch": "synced-flow watch" to package.json for local rebuilds during development.')
|
|
897
|
+
|
|
898
|
+
const tailwindDeps = ['tailwindcss', '@tailwindcss/postcss', '@tailwindcss/vite', 'tailwind-merge'].filter((name) => allDeps[name])
|
|
899
|
+
if (tailwindDeps.length) warn(`Tailwind packages still present: ${tailwindDeps.join(', ')}.`)
|
|
900
|
+
else pass('No Tailwind dependencies found.')
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const foundConfig = resolveConfigPath(readOption('config'), targetCwd)
|
|
905
|
+
if (!foundConfig) {
|
|
906
|
+
if (packageSource) pass('Package source does not require a consumer synced-flow.config.mjs.')
|
|
907
|
+
else fail('synced-flow.config.mjs was not found.')
|
|
908
|
+
} else {
|
|
909
|
+
pass(`Config found at ${relative(targetCwd, foundConfig)}.`)
|
|
910
|
+
const loaded = await loadConfig(foundConfig)
|
|
911
|
+
loadedConfig = loaded
|
|
912
|
+
if (Array.isArray(loaded.scan) && loaded.scan.length) pass(`Scan paths configured: ${loaded.scan.join(', ')}.`)
|
|
913
|
+
else fail('Config needs a non-empty scan array.')
|
|
914
|
+
|
|
915
|
+
const missingScans = (loaded.scan ?? []).filter((dir) => !existsSync(resolve(targetCwd, dir)))
|
|
916
|
+
if (missingScans.length) warn(`Some scan paths do not exist yet: ${missingScans.join(', ')}.`)
|
|
917
|
+
|
|
918
|
+
if (loaded.out) {
|
|
919
|
+
const generated = resolve(targetCwd, loaded.out)
|
|
920
|
+
if (existsSync(generated)) pass(`Generated CSS exists at ${loaded.out}.`)
|
|
921
|
+
else warn(`Generated CSS missing at ${loaded.out}; run synced-flow build.`)
|
|
922
|
+
} else {
|
|
923
|
+
warn('Config has no out path; defaulting to synced-flow.generated.css.')
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const check = spawnSync(process.execPath, [scriptFile, 'build', '--cwd', targetCwd, '--check', '--quiet'], {
|
|
927
|
+
encoding: 'utf8',
|
|
928
|
+
})
|
|
929
|
+
if (check.status === 0) pass('Generated CSS is up to date.')
|
|
930
|
+
else warn('Generated CSS is out of date or unsupported classes were found; run synced-flow build and review warnings.')
|
|
931
|
+
|
|
932
|
+
const lint = spawnSync(process.execPath, [scriptFile, 'lint', '--cwd', targetCwd, '--quiet'], {
|
|
933
|
+
encoding: 'utf8',
|
|
934
|
+
})
|
|
935
|
+
if (lint.status === 0) pass('No unsupported class tokens found.')
|
|
936
|
+
else warn('Unsupported class tokens were found; run synced-flow lint for nearest supported alternatives.')
|
|
937
|
+
|
|
938
|
+
const themeErrors = []
|
|
939
|
+
if (loaded.theme === undefined) warn('No theme configured; add a theme preset or run synced-flow theme init --from brief.md.')
|
|
940
|
+
else {
|
|
941
|
+
validateTheme(loaded.theme, themeErrors)
|
|
942
|
+
if (themeErrors.length) warn(`Theme config has ${themeErrors.length} issue(s); run synced-flow theme validate.`)
|
|
943
|
+
else pass('Theme config is valid.')
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (loaded.failOnUnsupported === false) warn('failOnUnsupported is disabled; enable it before release so unsupported classes fail the build.')
|
|
947
|
+
|
|
948
|
+
if (loaded.responsiveVariants) warn('responsiveVariants is enabled; use this only for migration projects.')
|
|
949
|
+
else pass('Strict fluid mode is enabled.')
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (loadedConfig?.includeCore) pass('Core CSS is included in generated output.')
|
|
953
|
+
else if (projectContains(targetCwd, '@syncedco/flow/styles.css') || projectContains(targetCwd, '@import "../../styles.css"')) pass('Core stylesheet import found.')
|
|
954
|
+
else warn('Import @syncedco/flow/styles.css from your app CSS or layout entry.')
|
|
955
|
+
|
|
956
|
+
const duplicateImports = findDuplicateCoreImports(targetCwd)
|
|
957
|
+
if (duplicateImports.length) {
|
|
958
|
+
warn(`Avoid duplicate core CSS imports in ${duplicateImports.join(', ')}. Use styles.css alone, or import modular layers without styles.css.`)
|
|
959
|
+
} else {
|
|
960
|
+
pass('No duplicate core stylesheet imports found.')
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const customTokenFiles = findCustomTokenOverrides(targetCwd, loadedConfig?.out)
|
|
964
|
+
if (customTokenFiles.length) {
|
|
965
|
+
if (packageSource) pass('Package source CSS owns the shipped --sf-* token definitions.')
|
|
966
|
+
else warn(`Custom --sf-* token overrides found in ${customTokenFiles.join(', ')}. Prefer synced-flow.config.mjs theme tokens for reusable brand decisions.`)
|
|
967
|
+
} else {
|
|
968
|
+
pass('No ad hoc --sf-* token overrides found in project CSS.')
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (packageSource && existsSync(resolve(targetCwd, 'skills/synced-flow/SKILL.md'))) pass('Packaged Synced Flow AI skill found.')
|
|
972
|
+
else if (hasAnyAgentGuidance(targetCwd)) pass('Synced Flow AI agent guidance found.')
|
|
973
|
+
else warn('Synced Flow AI agent guidance not found; run synced-flow agents install.')
|
|
974
|
+
|
|
975
|
+
for (const message of messages) console.log(message)
|
|
976
|
+
return failed ? 1 : 0
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function hasAnyAgentGuidance(targetCwd) {
|
|
980
|
+
return [
|
|
981
|
+
'AGENTS.md',
|
|
982
|
+
'.cursor/rules/synced-flow.mdc',
|
|
983
|
+
'.claude/skills/synced-flow/SKILL.md',
|
|
984
|
+
'.github/copilot-instructions.md',
|
|
985
|
+
'.github/instructions/synced-flow.instructions.md',
|
|
986
|
+
'.windsurf/rules/synced-flow.md',
|
|
987
|
+
'GEMINI.md',
|
|
988
|
+
'.aider.conf.yml',
|
|
989
|
+
].some((file) => {
|
|
990
|
+
const full = resolve(targetCwd, file)
|
|
991
|
+
return existsSync(full) && readFileSync(full, 'utf8').includes('Synced Flow')
|
|
992
|
+
})
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function isSyncedFlowSource(targetCwd) {
|
|
996
|
+
const packageFile = resolve(targetCwd, 'package.json')
|
|
997
|
+
if (!existsSync(packageFile)) return false
|
|
998
|
+
try {
|
|
999
|
+
return JSON.parse(readFileSync(packageFile, 'utf8')).name === '@syncedco/flow'
|
|
1000
|
+
} catch {
|
|
1001
|
+
return false
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function getTokenSummary() {
|
|
1006
|
+
return {
|
|
1007
|
+
type: {
|
|
1008
|
+
minStep: fluidConfig.typeMinStep,
|
|
1009
|
+
maxStep: fluidConfig.typeMaxStep,
|
|
1010
|
+
aliases: ['caption', 'body', 'lead', 'h4', 'h3', 'h2', 'h1', 'display'],
|
|
1011
|
+
},
|
|
1012
|
+
space: ['3xs', '2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl', 's-l', 'm-xl', 'l-2xl'],
|
|
1013
|
+
colours: {
|
|
1014
|
+
primitive: Object.keys(primitiveTokens.colours),
|
|
1015
|
+
semantic: Object.keys(semanticTokens.colours),
|
|
1016
|
+
},
|
|
1017
|
+
fonts: Object.keys(primitiveTokens.fonts),
|
|
1018
|
+
radii: Object.keys(primitiveTokens.radii),
|
|
1019
|
+
shadows: Object.keys(primitiveTokens.shadows),
|
|
1020
|
+
components: componentTokens,
|
|
1021
|
+
themePresets: presetNames.map(kebabThemeName),
|
|
1022
|
+
starterClasses: {
|
|
1023
|
+
layout: ['sf-container', 'sf-container--narrow', 'sf-container--wide', 'sf-container--full', 'sf-rail-shell', 'sf-rail-shell__rail', 'sf-rail-shell__body', 'sf-rail-shell__mark', 'sf-rail-shell__label', 'sf-section', 'sf-section--compact', 'sf-section--spacious', 'sf-stack', 'sf-flow', 'sf-cluster', 'sf-repel', 'sf-toolbar', 'sf-app-shell', 'sf-app-sidebar', 'sf-app-sidebar__brand', 'sf-app-main', 'sf-auto-grid', 'sf-auto-grid--xs', 'sf-auto-grid--sm', 'sf-auto-grid--lg', 'sf-switcher', 'sf-sidebar', 'sf-split', 'sf-split--reverse', 'sf-chapter', 'sf-chapter__copy', 'sf-frame', 'sf-cover', 'sf-panel-grid', 'sf-metric-grid', 'sf-pipeline', 'sf-scroll-viewport', 'sf-scroll-panel', 'sf-scroll-snap-y', 'sf-sticky-top', 'sf-media-object', 'sf-aside-rail'],
|
|
1024
|
+
components: ['sf-button', 'sf-button-group', 'sf-button--icon', 'sf-icon-button', 'sf-icon', 'sf-icon--xs', 'sf-icon--sm', 'sf-icon--md', 'sf-icon--lg', 'sf-icon--xl', 'sf-card', 'sf-card--flat', 'sf-card--raised', 'sf-card--compact', 'sf-card--glass', 'sf-card--technical', 'sf-card--accent', 'sf-card--center', 'sf-surface', 'sf-code-window', 'sf-code-window--glow', 'sf-code-window__bar', 'sf-window-dots', 'sf-code-lines', 'sf-code-lines--compact', 'sf-code-block', 'sf-token-strip', 'sf-marquee', 'sf-marquee__track', 'sf-marquee__item', 'sf-command-list', 'sf-command-list__prompt', 'sf-platform-card', 'sf-platform-card__logo', 'sf-hero', 'sf-logo-cloud', 'sf-feature', 'sf-stats', 'sf-stat', 'sf-testimonial', 'sf-pricing-grid', 'sf-price-card', 'sf-price', 'sf-faq', 'sf-cta', 'sf-footer', 'sf-badge', 'sf-avatar', 'sf-chart', 'sf-chart--wide', 'sf-chart__plot', 'sf-chart__svg', 'sf-chart__grid', 'sf-chart__axis', 'sf-chart__area', 'sf-chart__line', 'sf-chart__point', 'sf-chart__legend', 'sf-chart__legend-item', 'sf-chart__swatch', 'sf-meter-list', 'sf-meter-row', 'sf-meter', 'sf-nav', 'sf-nav--mobile', 'sf-nav__list', 'sf-nav__link', 'sf-menu', 'sf-breadcrumb', 'sf-pagination', 'sf-dialog', 'sf-popover', 'sf-tooltip', 'sf-tooltip-trigger', 'sf-menu-popover', 'sf-toast', 'sf-banner', 'sf-drawer', 'sf-drawer--stack', 'sf-disclosure', 'sf-accordion', 'sf-tabs', 'sf-tab-list', 'sf-tab', 'sf-tab-panel', 'sf-form', 'sf-fieldset', 'sf-field', 'sf-label', 'sf-help', 'sf-error', 'sf-input', 'sf-select', 'sf-textarea', 'sf-check', 'sf-alert', 'sf-section-header', 'sf-kicker'],
|
|
1025
|
+
type: ['sf-text-caption', 'sf-text-body', 'sf-text-lead', 'sf-text-h4', 'sf-text-h3', 'sf-text-h2', 'sf-text-h1', 'sf-text-display'],
|
|
1026
|
+
utilities: ['sr-only', 'not-sr-only', 'sf-visually-hidden', 'sf-not-visually-hidden', 'sf-skip-link', 'sf-focus-ring', 'sf-touch-target', 'sf-list-reset', 'sf-list-disc', 'sf-list-decimal', 'sf-link', 'sf-link-subtle', 'sf-link-plain', 'sf-prose', 'sf-prose--blog', 'sf-prose--legal', 'sf-meta', 'sf-figure', 'sf-caption', 'sf-table-wrap', 'sf-push-block-end', 'sf-push-block-start', 'sf-push-inline-end', 'sf-push-inline-start', 'sf-full-bleed', 'sf-text-muted', 'sf-bg-surface', 'sf-border', 'sf-rounded-panel', 'sf-shadow-md', 'sf-animate-fade', 'sf-animate-rise', 'sf-animate-scale', 'sf-animate-slide', 'sf-animate-stagger'],
|
|
1027
|
+
},
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function getPublicPatterns() {
|
|
1032
|
+
return [
|
|
1033
|
+
{
|
|
1034
|
+
id: 'mobile-nav-drawer',
|
|
1035
|
+
name: 'Mobile nav drawer',
|
|
1036
|
+
whenToUse: 'Responsive headers that need a burger button, popover-backed drawer, close button, and vertical navigation without shipping a JS component.',
|
|
1037
|
+
classes: ['sf-nav', 'sf-nav--mobile', 'sf-nav__list', 'sf-nav__link', 'sf-button', 'sf-button--ghost', 'sf-button--icon', 'sf-drawer', 'sf-drawer--right', 'sf-drawer--stack', 'sf-list-reset'],
|
|
1038
|
+
requiresJs: false,
|
|
1039
|
+
requiresJsNotes: 'Close-on-navigate uses button invokers with popoverTargetAction="hide". Scroll-spy active states need optional IntersectionObserver when aria-current should update while scrolling.',
|
|
1040
|
+
a11y: ['Use aria-label on the nav.', 'Use aria-current="page" or aria-current="location" on the active link.', 'Keep the menu trigger and close button as buttons, not anchors.'],
|
|
1041
|
+
gotchas: ['popovertarget only works on buttons.', 'Use sf-drawer--stack for vertical drawer flow.', 'Use formAction on nav buttons when you need hash navigation and popover close without custom JavaScript.'],
|
|
1042
|
+
markup: {
|
|
1043
|
+
html: `<header class="sf-section sf-section--compact sf-sticky-top sf-bg-background">
|
|
1044
|
+
<nav class="sf-container sf-nav sf-nav--mobile" aria-label="Main navigation">
|
|
1045
|
+
<a class="sf-nav__link" href="/">Site</a>
|
|
1046
|
+
<ul class="sf-nav__list">
|
|
1047
|
+
<li><a class="sf-nav__link" href="#work" aria-current="page">Work</a></li>
|
|
1048
|
+
<li><a class="sf-nav__link" href="#contact">Contact</a></li>
|
|
1049
|
+
</ul>
|
|
1050
|
+
<button class="sf-button sf-button--ghost sf-button--icon" type="button" popovertarget="site-mobile-menu" aria-label="Open menu">Menu</button>
|
|
1051
|
+
</nav>
|
|
1052
|
+
</header>
|
|
1053
|
+
<nav class="sf-drawer sf-drawer--right sf-drawer--stack" id="site-mobile-menu" popover="auto" aria-label="Mobile navigation">
|
|
1054
|
+
<button class="sf-button sf-button--ghost" type="button" popovertarget="site-mobile-menu" popovertargetaction="hide">Close</button>
|
|
1055
|
+
<form class="sf-stack" action="/" method="get">
|
|
1056
|
+
<button class="sf-nav__link" type="submit" formaction="#work" popovertarget="site-mobile-menu" popovertargetaction="hide">Work</button>
|
|
1057
|
+
<button class="sf-nav__link" type="submit" formaction="#contact" popovertarget="site-mobile-menu" popovertargetaction="hide">Contact</button>
|
|
1058
|
+
</form>
|
|
1059
|
+
</nav>`,
|
|
1060
|
+
},
|
|
1061
|
+
keywords: ['nav', 'navigation', 'mobile', 'drawer', 'burger', 'menu', 'popover', 'close'],
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
id: 'scroll-viewport-sections',
|
|
1065
|
+
name: 'Scroll viewport sections',
|
|
1066
|
+
whenToUse: 'Full-page scroll sections with a sticky header and hash navigation.',
|
|
1067
|
+
classes: ['sf-scroll-viewport', 'sf-scroll-snap-y', 'sf-scroll-panel', 'sf-sticky-top', 'sf-section', 'sf-container', 'sf-nav', 'sf-nav__link'],
|
|
1068
|
+
requiresJs: false,
|
|
1069
|
+
requiresJsNotes: 'Hash navigation and CSS scroll snap work without JavaScript. Live scroll-spy state requires optional JavaScript.',
|
|
1070
|
+
a11y: ['Use real section headings.', 'Set scroll-margin or use sf-scroll-panel for anchored sections.', 'Keep skip links outside the snapping viewport.'],
|
|
1071
|
+
gotchas: ['Scroll snap requires a constrained viewport height.', 'Do not nest another page scroller around sf-scroll-viewport.'],
|
|
1072
|
+
markup: {
|
|
1073
|
+
html: `<a class="sf-skip-link" href="#main">Skip to main content</a>
|
|
1074
|
+
<header class="sf-section sf-section--compact sf-sticky-top sf-bg-background">
|
|
1075
|
+
<nav class="sf-container sf-nav" aria-label="Section navigation">
|
|
1076
|
+
<a class="sf-nav__link" href="#intro" aria-current="location">Intro</a>
|
|
1077
|
+
<a class="sf-nav__link" href="#work">Work</a>
|
|
1078
|
+
<a class="sf-nav__link" href="#contact">Contact</a>
|
|
1079
|
+
</nav>
|
|
1080
|
+
</header>
|
|
1081
|
+
<main class="sf-scroll-viewport sf-scroll-snap-y" id="main">
|
|
1082
|
+
<section class="sf-scroll-panel" id="intro"><div class="sf-container sf-stack"><h1 class="sf-text-display">Intro panel</h1></div></section>
|
|
1083
|
+
<section class="sf-scroll-panel" id="work"><div class="sf-container sf-stack"><h2 class="sf-text-h1">Work panel</h2></div></section>
|
|
1084
|
+
<section class="sf-scroll-panel" id="contact"><div class="sf-container sf-stack"><h2 class="sf-text-h1">Contact panel</h2></div></section>
|
|
1085
|
+
</main>`,
|
|
1086
|
+
},
|
|
1087
|
+
keywords: ['scroll', 'snap', 'viewport', 'panel', 'portfolio', 'sections', 'sticky', 'hash'],
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
id: 'scroll-viewport-with-spy',
|
|
1091
|
+
name: 'Scroll viewport with optional spy',
|
|
1092
|
+
whenToUse: 'Scroll snap pages where the active navigation item should update while the user scrolls.',
|
|
1093
|
+
classes: ['sf-scroll-viewport', 'sf-scroll-panel', 'sf-sticky-top', 'sf-nav', 'sf-nav__link'],
|
|
1094
|
+
requiresJs: true,
|
|
1095
|
+
requiresJsNotes: 'CSS handles layout and snapping. IntersectionObserver is needed when aria-current should update during free scrolling.',
|
|
1096
|
+
a11y: ['Update aria-current on the active nav link.', 'Do not rely on colour alone for current state.', 'Respect prefers-reduced-motion for scripted scrolling.'],
|
|
1097
|
+
gotchas: ['Keep the observer script in the consuming app or example, not the Synced Flow package.', 'Anchor links still work without the optional script.'],
|
|
1098
|
+
markup: {
|
|
1099
|
+
html: `<nav class="sf-container sf-nav sf-sticky-top" aria-label="Section navigation">
|
|
1100
|
+
<a class="sf-nav__link" href="#one" aria-current="location">One</a>
|
|
1101
|
+
<a class="sf-nav__link" href="#two">Two</a>
|
|
1102
|
+
</nav>
|
|
1103
|
+
<main class="sf-scroll-viewport sf-scroll-snap-y" data-sf-scroll-spy>
|
|
1104
|
+
<section class="sf-scroll-panel" id="one"><div class="sf-container"><h1 class="sf-text-display">One</h1></div></section>
|
|
1105
|
+
<section class="sf-scroll-panel" id="two"><div class="sf-container"><h2 class="sf-text-h1">Two</h2></div></section>
|
|
1106
|
+
</main>
|
|
1107
|
+
<script>
|
|
1108
|
+
const links = [...document.querySelectorAll('[data-sf-scroll-spy] ~ * a[href^="#"], nav a[href^="#"]')]
|
|
1109
|
+
const panels = [...document.querySelectorAll('.sf-scroll-panel[id]')]
|
|
1110
|
+
const observer = new IntersectionObserver((entries) => {
|
|
1111
|
+
const active = entries.find((entry) => entry.isIntersecting)
|
|
1112
|
+
if (!active) return
|
|
1113
|
+
for (const link of links) link.toggleAttribute('aria-current', link.hash === '#' + active.target.id)
|
|
1114
|
+
}, { threshold: 0.6 })
|
|
1115
|
+
for (const panel of panels) observer.observe(panel)
|
|
1116
|
+
</script>`,
|
|
1117
|
+
},
|
|
1118
|
+
keywords: ['scroll', 'spy', 'intersectionobserver', 'portfolio', 'active', 'nav', 'sections'],
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
id: 'native-dialog-react',
|
|
1122
|
+
name: 'Native dialog for React',
|
|
1123
|
+
whenToUse: 'React or Next client components that use the browser dialog element rather than a JavaScript modal library.',
|
|
1124
|
+
classes: ['sf-dialog', 'sf-dialog__header', 'sf-dialog__body', 'sf-dialog__footer', 'sf-button', 'sf-button--outline'],
|
|
1125
|
+
requiresJs: true,
|
|
1126
|
+
requiresJsNotes: 'Opening a dialog with showModal() needs a small client handler until commandfor/command support is widely available.',
|
|
1127
|
+
a11y: ['Use a visible heading inside the dialog.', 'Provide a close button.', 'Use method="dialog" for native close actions.'],
|
|
1128
|
+
gotchas: ['commandfor/command is progressive enhancement.', 'In React, call showModal() from a client component for broad support.'],
|
|
1129
|
+
markup: {
|
|
1130
|
+
html: `<button class="sf-button" type="button" commandfor="demo-dialog" command="show-modal">Open dialog</button>
|
|
1131
|
+
<dialog class="sf-dialog" id="demo-dialog" aria-labelledby="demo-dialog-title">
|
|
1132
|
+
<div class="sf-dialog__header"><h2 class="sf-text-h3" id="demo-dialog-title">Native dialog</h2></div>
|
|
1133
|
+
<div class="sf-dialog__body"><p>Use native dialog first, with a small app-level fallback where needed.</p></div>
|
|
1134
|
+
<form class="sf-dialog__footer" method="dialog"><button class="sf-button sf-button--outline">Close</button></form>
|
|
1135
|
+
</dialog>`,
|
|
1136
|
+
next: `'use client'
|
|
1137
|
+
|
|
1138
|
+
import { useRef } from 'react'
|
|
1139
|
+
|
|
1140
|
+
export default function DialogExample() {
|
|
1141
|
+
const dialogRef = useRef(null)
|
|
1142
|
+
|
|
1143
|
+
return (
|
|
1144
|
+
<>
|
|
1145
|
+
<button className="sf-button" type="button" onClick={() => dialogRef.current?.showModal()}>Open dialog</button>
|
|
1146
|
+
<dialog className="sf-dialog" ref={dialogRef} aria-labelledby="demo-dialog-title">
|
|
1147
|
+
<div className="sf-dialog__header"><h2 className="sf-text-h3" id="demo-dialog-title">Native dialog</h2></div>
|
|
1148
|
+
<div className="sf-dialog__body"><p>Use native dialog first, with a small app-level fallback where needed.</p></div>
|
|
1149
|
+
<form className="sf-dialog__footer" method="dialog"><button className="sf-button sf-button--outline">Close</button></form>
|
|
1150
|
+
</dialog>
|
|
1151
|
+
</>
|
|
1152
|
+
)
|
|
1153
|
+
}`,
|
|
1154
|
+
},
|
|
1155
|
+
keywords: ['dialog', 'modal', 'react', 'next', 'native', 'commandfor', 'client'],
|
|
1156
|
+
},
|
|
1157
|
+
{
|
|
1158
|
+
id: 'popover-drawer-layout',
|
|
1159
|
+
name: 'Popover drawer layout',
|
|
1160
|
+
whenToUse: 'Popover-backed side panels, mobile menus, filters, and lightweight drawers.',
|
|
1161
|
+
classes: ['sf-drawer', 'sf-drawer--left', 'sf-drawer--right', 'sf-drawer--bottom', 'sf-drawer--stack', 'sf-popover', 'sf-button'],
|
|
1162
|
+
requiresJs: false,
|
|
1163
|
+
requiresJsNotes: 'Popover open/close is native when supported. Older browsers fall back to visible markup unless the consuming app adds a polyfill.',
|
|
1164
|
+
a11y: ['Use an accessible name on the drawer.', 'Provide a visible close button.', 'Keep trigger and close controls as buttons.'],
|
|
1165
|
+
gotchas: [':popover-open can change display; use sf-drawer--stack for vertical nav flow.', 'Do not attach popoverTarget to anchors.'],
|
|
1166
|
+
markup: {
|
|
1167
|
+
html: `<button class="sf-button" type="button" popovertarget="filters-drawer">Open filters</button>
|
|
1168
|
+
<aside class="sf-drawer sf-drawer--right sf-drawer--stack" id="filters-drawer" popover="auto" aria-label="Filters">
|
|
1169
|
+
<button class="sf-button sf-button--ghost" type="button" popovertarget="filters-drawer" popovertargetaction="hide">Close</button>
|
|
1170
|
+
<form class="sf-form">
|
|
1171
|
+
<label class="sf-field"><span class="sf-label">Search</span><input class="sf-input" type="search" /></label>
|
|
1172
|
+
<button class="sf-button" type="submit" popovertarget="filters-drawer" popovertargetaction="hide">Apply</button>
|
|
1173
|
+
</form>
|
|
1174
|
+
</aside>`,
|
|
1175
|
+
},
|
|
1176
|
+
keywords: ['popover', 'drawer', 'layout', 'filters', 'menu', 'mobile', 'stack'],
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
id: 'sticky-navigation',
|
|
1180
|
+
name: 'Sticky header and navigation',
|
|
1181
|
+
whenToUse: 'Global site headers, section navigation, scroll-spy style pages, and mobile nav shells.',
|
|
1182
|
+
requiresJs: false,
|
|
1183
|
+
requiresJsNotes: 'Sticky positioning and anchor navigation are CSS/HTML features. Scroll-spy state needs optional JavaScript.',
|
|
1184
|
+
a11y: ['Use aria-label on nav.', 'Use aria-current for active links.'],
|
|
1185
|
+
gotchas: ['Pair mobile versions with mobile-nav-drawer for complete drawer behavior.'],
|
|
1186
|
+
classes: ['sf-sticky-top', 'sf-nav', 'sf-nav--mobile', 'sf-nav__list', 'sf-nav__link', 'sf-menu', 'sf-button', 'sf-button--ghost'],
|
|
1187
|
+
markup: '<header class="sf-section sf-section--compact sf-sticky-top"><nav class="sf-container sf-nav">...</nav></header>',
|
|
1188
|
+
keywords: ['nav', 'navigation', 'header', 'sticky', 'mobile', 'menu', 'scroll-spy'],
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
id: 'hero-split',
|
|
1192
|
+
name: 'Split hero',
|
|
1193
|
+
whenToUse: 'Landing page intros with copy, actions, media, and a supporting stats or proof block.',
|
|
1194
|
+
classes: ['sf-section', 'sf-container', 'sf-split', 'sf-stack', 'sf-kicker', 'sf-text-display', 'sf-text-lead', 'sf-button', 'sf-frame'],
|
|
1195
|
+
markup: '<section class="sf-section"><div class="sf-container sf-split">...</div></section>',
|
|
1196
|
+
keywords: ['hero', 'intro', 'split', 'landing', 'media', 'cta'],
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
id: 'card-grid',
|
|
1200
|
+
name: 'Feature or card grid',
|
|
1201
|
+
whenToUse: 'Feature lists, service cards, pricing summaries, team cards, and blog indexes.',
|
|
1202
|
+
classes: ['sf-section', 'sf-container', 'sf-section-header', 'sf-auto-grid', 'sf-card', 'sf-card--interactive', 'sf-badge'],
|
|
1203
|
+
markup: '<section class="sf-section"><div class="sf-container sf-stack"><div class="sf-auto-grid">...</div></div></section>',
|
|
1204
|
+
keywords: ['feature', 'features', 'grid', 'cards', 'services', 'team', 'pricing', 'blog'],
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
id: 'native-dialog-drawer',
|
|
1208
|
+
name: 'Native dialog, popover, and drawer',
|
|
1209
|
+
whenToUse: 'Modals, mobile menus, cookie notices, command menus, lightweight drawers, and non-JS native overlays.',
|
|
1210
|
+
classes: ['sf-dialog', 'sf-dialog__header', 'sf-dialog__body', 'sf-dialog__footer', 'sf-popover', 'sf-drawer', 'sf-toast', 'sf-banner'],
|
|
1211
|
+
markup: '<dialog class="sf-dialog">...</dialog><aside popover class="sf-drawer sf-drawer--right">...</aside>',
|
|
1212
|
+
keywords: ['dialog', 'modal', 'drawer', 'popover', 'toast', 'banner', 'cookie', 'overlay', 'menu'],
|
|
1213
|
+
},
|
|
1214
|
+
{
|
|
1215
|
+
id: 'faq-disclosure-tabs',
|
|
1216
|
+
name: 'FAQ, disclosure, and tabs',
|
|
1217
|
+
whenToUse: 'FAQs, filters, mobile sections, feature tabs, pricing toggles, and compact long content.',
|
|
1218
|
+
classes: ['sf-disclosure', 'sf-accordion', 'sf-tabs', 'sf-tab-list', 'sf-tab', 'sf-tab-panel', 'sf-faq'],
|
|
1219
|
+
markup: '<details class="sf-disclosure"><summary>Question</summary><p>Answer</p></details>',
|
|
1220
|
+
keywords: ['faq', 'accordion', 'details', 'summary', 'tabs', 'toggle', 'filters', 'pricing'],
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
id: 'scroll-snap-page',
|
|
1224
|
+
name: 'Scroll snap page sections',
|
|
1225
|
+
whenToUse: 'Portfolio panels, product storytelling, presentation-style pages, and full-page feature sections.',
|
|
1226
|
+
classes: ['sf-scroll-viewport', 'sf-scroll-snap-y', 'sf-scroll-panel', 'sf-sticky-top', 'sf-section', 'sf-container'],
|
|
1227
|
+
markup: '<main class="sf-scroll-viewport" data-snap="mandatory"><section class="sf-scroll-panel">...</section></main>',
|
|
1228
|
+
keywords: ['scroll', 'snap', 'panel', 'portfolio', 'presentation', 'full-page', 'sections'],
|
|
1229
|
+
},
|
|
1230
|
+
{
|
|
1231
|
+
id: 'long-form-content',
|
|
1232
|
+
name: 'Blog article and long-form content',
|
|
1233
|
+
whenToUse: 'Articles, legal pages, documentation, case studies, figures, captions, metadata, and responsive tables.',
|
|
1234
|
+
classes: ['sf-prose', 'sf-prose--blog', 'sf-prose--legal', 'sf-meta', 'sf-figure', 'sf-caption', 'sf-table-wrap', 'sf-breadcrumb'],
|
|
1235
|
+
markup: '<article class="sf-container sf-prose sf-prose--blog">...</article>',
|
|
1236
|
+
keywords: ['blog', 'article', 'prose', 'legal', 'docs', 'case-study', 'table', 'figure', 'seo'],
|
|
1237
|
+
},
|
|
1238
|
+
{
|
|
1239
|
+
id: 'contact-form',
|
|
1240
|
+
name: 'Accessible contact form',
|
|
1241
|
+
whenToUse: 'Contact forms, sign-up forms, newsletter forms, and simple application flows.',
|
|
1242
|
+
classes: ['sf-form', 'sf-fieldset', 'sf-field', 'sf-label', 'sf-help', 'sf-error', 'sf-input', 'sf-select', 'sf-textarea', 'sf-check', 'sf-button'],
|
|
1243
|
+
markup: '<form class="sf-form"><label class="sf-field"><span class="sf-label">Email</span><input class="sf-input"></label></form>',
|
|
1244
|
+
keywords: ['form', 'contact', 'signup', 'newsletter', 'input', 'select', 'textarea', 'checkbox'],
|
|
1245
|
+
},
|
|
1246
|
+
]
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function getPublicRecipes() {
|
|
1250
|
+
return [
|
|
1251
|
+
{
|
|
1252
|
+
id: 'saas-landing',
|
|
1253
|
+
name: 'SaaS landing page',
|
|
1254
|
+
whenToUse: 'Product marketing pages with hero, proof, features, pricing, FAQ, CTA, and footer.',
|
|
1255
|
+
sections: ['sticky-navigation', 'hero-split', 'card-grid', 'faq-disclosure-tabs', 'contact-form'],
|
|
1256
|
+
classes: ['sf-sticky-top', 'sf-nav', 'sf-section', 'sf-container', 'sf-split', 'sf-card', 'sf-auto-grid', 'sf-pricing-grid', 'sf-faq', 'sf-cta', 'sf-footer'],
|
|
1257
|
+
keywords: ['saas', 'landing', 'marketing', 'pricing', 'feature', 'faq', 'cta', 'product'],
|
|
1258
|
+
markup: `<a class="sf-skip-link" href="#main">Skip to main content</a>
|
|
1259
|
+
<header class="sf-section sf-section--compact sf-sticky-top sf-bg-background">
|
|
1260
|
+
<nav class="sf-container sf-nav" aria-label="Main navigation">
|
|
1261
|
+
<a class="sf-nav__link" href="/">Product</a>
|
|
1262
|
+
<ul class="sf-nav__list">
|
|
1263
|
+
<li><a class="sf-nav__link" href="#features">Features</a></li>
|
|
1264
|
+
<li><a class="sf-nav__link" href="#pricing">Pricing</a></li>
|
|
1265
|
+
<li><a class="sf-nav__link" href="#faq">FAQ</a></li>
|
|
1266
|
+
</ul>
|
|
1267
|
+
<a class="sf-button sf-button--sm" href="#contact">Book demo</a>
|
|
1268
|
+
</nav>
|
|
1269
|
+
</header>
|
|
1270
|
+
<main id="main">
|
|
1271
|
+
<section class="sf-section">
|
|
1272
|
+
<div class="sf-container sf-split">
|
|
1273
|
+
<div class="sf-stack">
|
|
1274
|
+
<p class="sf-kicker">Launch faster</p>
|
|
1275
|
+
<h1 class="sf-text-display">A modern product site without page-specific CSS.</h1>
|
|
1276
|
+
<p class="sf-text-lead sf-prose">Compose hero, proof, pricing, FAQ, and contact sections from Synced Flow primitives.</p>
|
|
1277
|
+
<div class="sf-cluster">
|
|
1278
|
+
<a class="sf-button sf-button--default" href="#contact">Start free</a>
|
|
1279
|
+
<a class="sf-button sf-button--outline" href="#features">View features</a>
|
|
1280
|
+
</div>
|
|
1281
|
+
</div>
|
|
1282
|
+
<aside class="sf-card sf-stack">
|
|
1283
|
+
<p class="sf-badge">Included</p>
|
|
1284
|
+
<ul class="sf-list-disc">
|
|
1285
|
+
<li>Fluid tokens</li>
|
|
1286
|
+
<li>Native components</li>
|
|
1287
|
+
<li>Accessible states</li>
|
|
1288
|
+
</ul>
|
|
1289
|
+
</aside>
|
|
1290
|
+
</div>
|
|
1291
|
+
</section>
|
|
1292
|
+
<section class="sf-section" id="features">
|
|
1293
|
+
<div class="sf-container sf-stack">
|
|
1294
|
+
<header class="sf-section-header" data-align="center">
|
|
1295
|
+
<p class="sf-kicker">Features</p>
|
|
1296
|
+
<h2 class="sf-text-h2">Everything a basic website needs.</h2>
|
|
1297
|
+
</header>
|
|
1298
|
+
<div class="sf-auto-grid">
|
|
1299
|
+
<article class="sf-card sf-card--interactive sf-stack"><h3 class="sf-text-h4">Tokens</h3><p class="sf-text-muted">Theme type, colour, radius, motion, and surfaces.</p></article>
|
|
1300
|
+
<article class="sf-card sf-card--interactive sf-stack"><h3 class="sf-text-h4">Patterns</h3><p class="sf-text-muted">Hero, cards, nav, forms, pricing, FAQ, and CTA.</p></article>
|
|
1301
|
+
<article class="sf-card sf-card--interactive sf-stack"><h3 class="sf-text-h4">Guardrails</h3><p class="sf-text-muted">Doctor, lint, catalog, and recipe discovery.</p></article>
|
|
1302
|
+
</div>
|
|
1303
|
+
</div>
|
|
1304
|
+
</section>
|
|
1305
|
+
<section class="sf-section" id="pricing">
|
|
1306
|
+
<div class="sf-container sf-stack">
|
|
1307
|
+
<header class="sf-section-header" data-align="center"><p class="sf-kicker">Pricing</p><h2 class="sf-text-h2">Simple plans.</h2></header>
|
|
1308
|
+
<div class="sf-pricing-grid">
|
|
1309
|
+
<article class="sf-price-card"><h3>Starter</h3><p class="sf-price">$19</p><a class="sf-button sf-button--outline" href="#contact">Choose Starter</a></article>
|
|
1310
|
+
<article class="sf-price-card" data-featured="true"><h3>Team</h3><p class="sf-price">$49</p><a class="sf-button" href="#contact">Choose Team</a></article>
|
|
1311
|
+
</div>
|
|
1312
|
+
</div>
|
|
1313
|
+
</section>
|
|
1314
|
+
</main>`,
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
id: 'portfolio-scroll',
|
|
1318
|
+
name: 'Scroll snap portfolio',
|
|
1319
|
+
whenToUse: 'Portfolio, case study, or studio pages that use full-panel storytelling.',
|
|
1320
|
+
sections: ['sticky-navigation', 'scroll-snap-page', 'card-grid', 'contact-form'],
|
|
1321
|
+
classes: ['sf-sticky-top', 'sf-nav--mobile', 'sf-drawer', 'sf-drawer--stack', 'sf-scroll-viewport', 'sf-scroll-panel', 'sf-split', 'sf-card', 'sf-frame', 'sf-button'],
|
|
1322
|
+
keywords: ['portfolio', 'scroll', 'snap', 'case', 'study', 'studio', 'full-page', 'panel'],
|
|
1323
|
+
markup: `<header class="sf-section sf-section--compact sf-sticky-top sf-bg-background">
|
|
1324
|
+
<nav class="sf-container sf-nav sf-nav--mobile" aria-label="Portfolio navigation">
|
|
1325
|
+
<a class="sf-nav__link" href="#intro">Studio</a>
|
|
1326
|
+
<ul class="sf-nav__list"><li><a class="sf-nav__link" href="#work">Work</a></li><li><a class="sf-nav__link" href="#contact">Contact</a></li></ul>
|
|
1327
|
+
<button class="sf-button sf-button--ghost sf-button--icon" type="button" popovertarget="portfolio-mobile-menu" aria-label="Open menu">Menu</button>
|
|
1328
|
+
</nav>
|
|
1329
|
+
</header>
|
|
1330
|
+
<nav class="sf-drawer sf-drawer--right sf-drawer--stack" id="portfolio-mobile-menu" popover="auto" aria-label="Mobile portfolio navigation">
|
|
1331
|
+
<button class="sf-button sf-button--ghost" type="button" popovertarget="portfolio-mobile-menu" popovertargetaction="hide">Close</button>
|
|
1332
|
+
<form class="sf-stack" action="/" method="get">
|
|
1333
|
+
<button class="sf-nav__link" type="submit" formaction="#intro" popovertarget="portfolio-mobile-menu" popovertargetaction="hide">Intro</button>
|
|
1334
|
+
<button class="sf-nav__link" type="submit" formaction="#work" popovertarget="portfolio-mobile-menu" popovertargetaction="hide">Work</button>
|
|
1335
|
+
<button class="sf-nav__link" type="submit" formaction="#contact" popovertarget="portfolio-mobile-menu" popovertargetaction="hide">Contact</button>
|
|
1336
|
+
</form>
|
|
1337
|
+
</nav>
|
|
1338
|
+
<main class="sf-scroll-viewport" data-snap="mandatory">
|
|
1339
|
+
<section class="sf-scroll-panel" id="intro">
|
|
1340
|
+
<div class="sf-container sf-stack">
|
|
1341
|
+
<p class="sf-kicker">Selected work</p>
|
|
1342
|
+
<h1 class="sf-text-display">Fluid portfolio panels with native scroll snap.</h1>
|
|
1343
|
+
<p class="sf-text-lead sf-prose">Use each panel for one story, proof point, or call to action.</p>
|
|
1344
|
+
</div>
|
|
1345
|
+
</section>
|
|
1346
|
+
<section class="sf-scroll-panel" id="work">
|
|
1347
|
+
<div class="sf-container sf-split">
|
|
1348
|
+
<div class="sf-card sf-stack"><p class="sf-badge">Case study</p><h2 class="sf-text-h2">Project title</h2><p class="sf-text-muted">Replace this panel with project details.</p></div>
|
|
1349
|
+
<div class="sf-frame sf-card"></div>
|
|
1350
|
+
</div>
|
|
1351
|
+
</section>
|
|
1352
|
+
<section class="sf-scroll-panel" id="contact">
|
|
1353
|
+
<div class="sf-container sf-stack"><h2 class="sf-text-h2">Let's build the next one.</h2><a class="sf-button" href="mailto:hello@example.com">Start a project</a></div>
|
|
1354
|
+
</section>
|
|
1355
|
+
</main>`,
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
id: 'saas-dashboard',
|
|
1359
|
+
name: 'SaaS dashboard',
|
|
1360
|
+
whenToUse: 'Authenticated SaaS apps, admin panels, customer portals, CRMs, analytics dashboards, and product workspaces.',
|
|
1361
|
+
sections: ['sticky-navigation', 'mobile-nav-drawer', 'card-grid', 'contact-form', 'long-form-content'],
|
|
1362
|
+
classes: ['sf-skip-link', 'sf-theme-light', 'sf-app-shell', 'sf-app-sidebar', 'sf-app-sidebar__brand', 'sf-app-main', 'sf-toolbar', 'sf-stack', 'sf-stack--tight', 'sf-repel', 'sf-cluster', 'sf-panel-grid', 'sf-metric-grid', 'sf-nav', 'sf-nav__list', 'sf-nav__link', 'sf-icon', 'sf-card', 'sf-surface', 'sf-stat', 'sf-badge', 'sf-avatar', 'sf-chart', 'sf-chart--wide', 'sf-chart__plot', 'sf-chart__svg', 'sf-chart__grid', 'sf-chart__axis', 'sf-chart__area', 'sf-chart__line', 'sf-chart__point', 'sf-chart__legend', 'sf-chart__legend-item', 'sf-chart__swatch', 'sf-meter-list', 'sf-meter-row', 'sf-meter', 'sf-button', 'sf-button--outline', 'sf-button--icon', 'sf-form', 'sf-field', 'sf-input', 'sf-check', 'sf-table-wrap', 'sf-font-sans', 'sf-visually-hidden'],
|
|
1363
|
+
keywords: ['saas', 'dashboard', 'admin', 'panel', 'portal', 'crm', 'analytics', 'app', 'auth', 'login', 'signin', 'sign-in', 'user', 'account', 'metrics', 'pipeline', 'activity', 'customers', 'accounts', 'tasks', 'tickets', 'revenue'],
|
|
1364
|
+
markup: `<a class="sf-skip-link" href="#dashboard-main">Skip to dashboard</a>
|
|
1365
|
+
<div class="sf-app-shell sf-theme-light">
|
|
1366
|
+
<aside class="sf-app-sidebar sf-stack" aria-label="Workspace navigation">
|
|
1367
|
+
<div class="sf-app-sidebar__brand">
|
|
1368
|
+
<span class="sf-badge" aria-hidden="true">SF</span>
|
|
1369
|
+
<div><strong>Product Suite</strong><br><span class="sf-text-muted">Workspace</span></div>
|
|
1370
|
+
</div>
|
|
1371
|
+
<nav class="sf-nav" aria-label="Primary">
|
|
1372
|
+
<p class="sf-kicker">Overview</p>
|
|
1373
|
+
<ul class="sf-nav__list">
|
|
1374
|
+
<li><a class="sf-nav__link" href="#dashboard-main" aria-current="page"><svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"></rect><rect width="7" height="5" x="14" y="3" rx="1"></rect><rect width="7" height="9" x="14" y="12" rx="1"></rect><rect width="7" height="5" x="3" y="16" rx="1"></rect></svg><span>Dashboard</span></a></li>
|
|
1375
|
+
<li><a class="sf-nav__link" href="#command"><svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" x2="20" y1="19" y2="19"></line></svg><span>Command centre</span></a></li>
|
|
1376
|
+
</ul>
|
|
1377
|
+
<p class="sf-kicker">Workspace</p>
|
|
1378
|
+
<ul class="sf-nav__list">
|
|
1379
|
+
<li><a class="sf-nav__link" href="#customers"><svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg><span>Customers</span></a></li>
|
|
1380
|
+
<li><a class="sf-nav__link" href="#activity"><svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"></path></svg><span>Activity</span></a></li>
|
|
1381
|
+
<li><a class="sf-nav__link" href="#billing"><svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"></rect><line x1="2" x2="22" y1="10" y2="10"></line></svg><span>Billing</span></a></li>
|
|
1382
|
+
</ul>
|
|
1383
|
+
<p class="sf-kicker">System</p>
|
|
1384
|
+
<ul class="sf-nav__list">
|
|
1385
|
+
<li><a class="sf-nav__link" href="/app/settings"><svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></svg><span>Settings</span></a></li>
|
|
1386
|
+
</ul>
|
|
1387
|
+
</nav>
|
|
1388
|
+
<footer class="sf-stack sf-stack--tight sf-push-block-end">
|
|
1389
|
+
<div class="sf-cluster">
|
|
1390
|
+
<span class="sf-badge" aria-hidden="true">S</span>
|
|
1391
|
+
<span><strong>Account owner</strong><br><span class="sf-text-muted">Admin role</span></span>
|
|
1392
|
+
</div>
|
|
1393
|
+
<form action="/logout" method="post"><button class="sf-button sf-button--ghost sf-button--block" type="submit">Sign out</button></form>
|
|
1394
|
+
<p class="sf-text-caption sf-text-muted">Product Suite v1.0</p>
|
|
1395
|
+
</footer>
|
|
1396
|
+
</aside>
|
|
1397
|
+
<main class="sf-app-main sf-stack sf-stack--loose" id="dashboard-main">
|
|
1398
|
+
<header class="sf-toolbar">
|
|
1399
|
+
<div class="sf-cluster">
|
|
1400
|
+
<button class="sf-button sf-button--outline sf-button--icon" type="button" aria-label="Open workspace menu">
|
|
1401
|
+
<svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="7" y2="7"></line><line x1="4" x2="20" y1="12" y2="12"></line><line x1="4" x2="20" y1="17" y2="17"></line></svg>
|
|
1402
|
+
</button>
|
|
1403
|
+
<form class="sf-field" role="search">
|
|
1404
|
+
<label class="sf-visually-hidden" for="dashboard-search">Search workspace</label>
|
|
1405
|
+
<input class="sf-input" id="dashboard-search" type="search" placeholder="Search or type command..." size="36">
|
|
1406
|
+
</form>
|
|
1407
|
+
<span class="sf-badge" aria-hidden="true">Cmd K</span>
|
|
1408
|
+
</div>
|
|
1409
|
+
<div class="sf-cluster" aria-label="Theme, notifications, and signed-in user">
|
|
1410
|
+
<button class="sf-button sf-button--outline sf-button--icon" type="button" aria-label="Use light theme" aria-pressed="true">
|
|
1411
|
+
<svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="m4.93 4.93 1.41 1.41"></path><path d="m17.66 17.66 1.41 1.41"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="m6.34 17.66-1.41 1.41"></path><path d="m19.07 4.93-1.41 1.41"></path></svg>
|
|
1412
|
+
</button>
|
|
1413
|
+
<button class="sf-button sf-button--outline sf-button--icon" type="button" aria-label="Use dark theme" aria-pressed="false">
|
|
1414
|
+
<svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 7.4A9 9 0 1 1 12 3Z"></path></svg>
|
|
1415
|
+
</button>
|
|
1416
|
+
<button class="sf-button sf-button--outline sf-button--icon" type="button" aria-label="View notifications">
|
|
1417
|
+
<svg class="sf-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.27 21a2 2 0 0 0 3.46 0"></path><path d="M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"></path></svg>
|
|
1418
|
+
</button>
|
|
1419
|
+
<div class="sf-cluster" aria-label="Signed in as Admin">
|
|
1420
|
+
<span class="sf-avatar" aria-hidden="true">A</span>
|
|
1421
|
+
<strong>Admin</strong>
|
|
1422
|
+
</div>
|
|
1423
|
+
</div>
|
|
1424
|
+
</header>
|
|
1425
|
+
<section class="sf-metric-grid" aria-label="Workspace metrics">
|
|
1426
|
+
<article class="sf-card sf-stack sf-stack--tight"><div class="sf-repel"><p class="sf-kicker">Accounts</p><span class="sf-badge">+12</span></div><strong class="sf-text-h3 sf-font-sans">128</strong><span class="sf-text-muted">Active workspaces</span></article>
|
|
1427
|
+
<article class="sf-card sf-stack sf-stack--tight"><div class="sf-repel"><p class="sf-kicker">Revenue</p><span class="sf-badge">Month</span></div><strong class="sf-text-h3 sf-font-sans">£78k</strong><span class="sf-text-muted">Tracked this month</span></article>
|
|
1428
|
+
<article class="sf-card sf-stack sf-stack--tight"><div class="sf-repel"><p class="sf-kicker">Tasks</p><span class="sf-badge">5 due</span></div><strong class="sf-text-h3 sf-font-sans">24</strong><span class="sf-text-muted">Open actions</span></article>
|
|
1429
|
+
<article class="sf-card sf-stack sf-stack--tight"><div class="sf-repel"><p class="sf-kicker">Health</p><span class="sf-badge">Good</span></div><strong class="sf-text-h3 sf-font-sans">94%</strong><span class="sf-text-muted">Across accounts</span></article>
|
|
1430
|
+
</section>
|
|
1431
|
+
<section class="sf-panel-grid" data-density="featured" aria-labelledby="analytics-title">
|
|
1432
|
+
<article class="sf-card sf-chart sf-chart--wide sf-stack" aria-labelledby="analytics-title">
|
|
1433
|
+
<div class="sf-repel">
|
|
1434
|
+
<div><p class="sf-kicker">Analytics</p><h2 class="sf-card__title sf-font-sans" id="analytics-title">Revenue trend</h2></div>
|
|
1435
|
+
<span class="sf-badge">+12%</span>
|
|
1436
|
+
</div>
|
|
1437
|
+
<div class="sf-chart__plot">
|
|
1438
|
+
<svg class="sf-chart__svg" viewBox="0 0 320 160" role="img" aria-labelledby="revenue-chart-title revenue-chart-desc">
|
|
1439
|
+
<title id="revenue-chart-title">Revenue trend for the last six months</title>
|
|
1440
|
+
<desc id="revenue-chart-desc">Revenue rises from twenty eight thousand pounds to seventy eight thousand pounds with one mid-period dip.</desc>
|
|
1441
|
+
<path class="sf-chart__grid" d="M20 30H300M20 75H300M20 120H300"></path>
|
|
1442
|
+
<path class="sf-chart__axis" d="M20 20V140H304"></path>
|
|
1443
|
+
<path class="sf-chart__area" d="M20 124L72 104L124 112L176 76L228 54L280 34L280 140L20 140Z"></path>
|
|
1444
|
+
<path class="sf-chart__line" d="M20 124L72 104L124 112L176 76L228 54L280 34"></path>
|
|
1445
|
+
<circle class="sf-chart__point" cx="280" cy="34" r="4"></circle>
|
|
1446
|
+
</svg>
|
|
1447
|
+
</div>
|
|
1448
|
+
<div class="sf-chart__legend" aria-label="Chart legend">
|
|
1449
|
+
<span class="sf-chart__legend-item"><span class="sf-chart__swatch" aria-hidden="true"></span>Revenue</span>
|
|
1450
|
+
<span class="sf-text-muted">£78k current month</span>
|
|
1451
|
+
</div>
|
|
1452
|
+
</article>
|
|
1453
|
+
<article class="sf-card sf-chart sf-stack" aria-labelledby="capacity-title">
|
|
1454
|
+
<div><p class="sf-kicker">Capacity</p><h2 class="sf-card__title sf-font-sans" id="capacity-title">Usage by area</h2></div>
|
|
1455
|
+
<div class="sf-meter-list">
|
|
1456
|
+
<label class="sf-meter-row"><span class="sf-repel"><strong>Accounts</strong><span class="sf-text-muted">82%</span></span><meter class="sf-meter" min="0" max="100" value="82">82%</meter></label>
|
|
1457
|
+
<label class="sf-meter-row"><span class="sf-repel"><strong>Automation</strong><span class="sf-text-muted">64%</span></span><meter class="sf-meter" min="0" max="100" value="64">64%</meter></label>
|
|
1458
|
+
<label class="sf-meter-row"><span class="sf-repel"><strong>Support</strong><span class="sf-text-muted">48%</span></span><meter class="sf-meter" min="0" max="100" value="48">48%</meter></label>
|
|
1459
|
+
</div>
|
|
1460
|
+
</article>
|
|
1461
|
+
</section>
|
|
1462
|
+
<section class="sf-metric-grid" id="command">
|
|
1463
|
+
<article class="sf-card sf-stack"><div class="sf-repel"><h2 class="sf-card__title sf-font-sans">Command centre</h2><span class="sf-badge">Priority</span></div><p class="sf-card__description">Follow-ups, approvals, and missing next actions stay visible.</p></article>
|
|
1464
|
+
<article class="sf-card sf-stack" id="billing"><h2 class="sf-card__title sf-font-sans">Billing watch</h2><p class="sf-card__description">2 overdue payment follow-ups and 1 renewal due this week.</p></article>
|
|
1465
|
+
<article class="sf-card sf-stack"><h2 class="sf-card__title sf-font-sans">Relationship pulse</h2><p class="sf-card__description">Three customer timelines need a recent touch point.</p></article>
|
|
1466
|
+
</section>
|
|
1467
|
+
<section class="sf-panel-grid" id="customers">
|
|
1468
|
+
<article class="sf-card sf-stack">
|
|
1469
|
+
<div class="sf-repel"><h2 class="sf-card__title sf-font-sans">Recent activity</h2><a class="sf-link" href="/app/activity">View all</a></div>
|
|
1470
|
+
<div class="sf-table-wrap">
|
|
1471
|
+
<table>
|
|
1472
|
+
<thead><tr><th>Item</th><th>Status</th><th>Value</th><th>Date</th></tr></thead>
|
|
1473
|
+
<tbody>
|
|
1474
|
+
<tr><td>Enterprise onboarding</td><td><span class="sf-badge">Active</span></td><td>£7,800</td><td>Today</td></tr>
|
|
1475
|
+
<tr><td>Usage review</td><td><span class="sf-badge">Scheduled</span></td><td>£4,050</td><td>Yesterday</td></tr>
|
|
1476
|
+
<tr><td>Renewal workflow</td><td><span class="sf-badge">Complete</span></td><td>£3,250</td><td>17 May</td></tr>
|
|
1477
|
+
</tbody>
|
|
1478
|
+
</table>
|
|
1479
|
+
</div>
|
|
1480
|
+
</article>
|
|
1481
|
+
<article class="sf-card sf-stack" id="activity">
|
|
1482
|
+
<div class="sf-repel"><h2 class="sf-card__title sf-font-sans">Recent customers</h2><a class="sf-link" href="/app/customers">View all</a></div>
|
|
1483
|
+
<ul class="sf-list-reset sf-stack">
|
|
1484
|
+
<li class="sf-repel"><span><strong>Northstar Labs</strong><br><span class="sf-text-muted">Growth workspace</span></span><span class="sf-text-muted">Healthy</span></li>
|
|
1485
|
+
<li class="sf-repel"><span><strong>Willow Group</strong><br><span class="sf-text-muted">Operations workspace</span></span><span class="sf-text-muted">Needs review</span></li>
|
|
1486
|
+
<li class="sf-repel"><span><strong>Harbour Systems</strong><br><span class="sf-text-muted">Platform workspace</span></span><span class="sf-text-muted">Healthy</span></li>
|
|
1487
|
+
</ul>
|
|
1488
|
+
</article>
|
|
1489
|
+
</section>
|
|
1490
|
+
<section class="sf-card sf-form" aria-labelledby="signin-title">
|
|
1491
|
+
<!-- Example unauthenticated state. Wire this form to your app auth provider. -->
|
|
1492
|
+
<div class="sf-repel">
|
|
1493
|
+
<div><p class="sf-kicker">Signed out state</p><h2 class="sf-card__title sf-font-sans" id="signin-title">Sign in to your workspace</h2></div>
|
|
1494
|
+
<span class="sf-badge">Auth UI only</span>
|
|
1495
|
+
</div>
|
|
1496
|
+
<div class="sf-panel-grid">
|
|
1497
|
+
<label class="sf-field"><span class="sf-label">Email</span><input class="sf-input" type="email" autocomplete="email" placeholder="you@example.com"></label>
|
|
1498
|
+
<label class="sf-field"><span class="sf-label">Password</span><input class="sf-input" type="password" autocomplete="current-password"></label>
|
|
1499
|
+
</div>
|
|
1500
|
+
<label class="sf-check"><input type="checkbox" name="remember"> <span>Remember this device</span></label>
|
|
1501
|
+
<div class="sf-cluster"><button class="sf-button" type="submit">Sign in</button><a class="sf-link" href="/forgot-password">Forgot password?</a></div>
|
|
1502
|
+
</section>
|
|
1503
|
+
</main>
|
|
1504
|
+
</div>`,
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
id: 'agency-home',
|
|
1508
|
+
name: 'Agency homepage',
|
|
1509
|
+
whenToUse: 'Agency, consultancy, or service business websites with services, proof, team, and contact.',
|
|
1510
|
+
sections: ['sticky-navigation', 'hero-split', 'card-grid', 'contact-form'],
|
|
1511
|
+
classes: ['sf-nav', 'sf-hero', 'sf-split', 'sf-feature', 'sf-testimonial', 'sf-stats', 'sf-form', 'sf-footer'],
|
|
1512
|
+
keywords: ['agency', 'consultancy', 'service', 'team', 'proof', 'testimonial', 'contact'],
|
|
1513
|
+
markup: `<main>
|
|
1514
|
+
<section class="sf-hero">
|
|
1515
|
+
<div class="sf-container sf-split">
|
|
1516
|
+
<div class="sf-stack"><p class="sf-kicker">Agency</p><h1 class="sf-text-display">Strategy, design, and build in one fluid system.</h1><p class="sf-text-lead sf-prose">A complete service homepage using Synced Flow primitives.</p><a class="sf-button" href="#contact">Plan a project</a></div>
|
|
1517
|
+
<aside class="sf-stats"><article class="sf-stat"><strong class="sf-stat__value">12</strong><span class="sf-stat__label">Years</span></article><article class="sf-stat"><strong class="sf-stat__value">80+</strong><span class="sf-stat__label">Launches</span></article></aside>
|
|
1518
|
+
</div>
|
|
1519
|
+
</section>
|
|
1520
|
+
<section class="sf-section"><div class="sf-container sf-auto-grid"><article class="sf-feature"><h2 class="sf-feature__title">Positioning</h2><p class="sf-feature__text">Turn offers into clear site structure.</p></article><article class="sf-feature"><h2 class="sf-feature__title">Design systems</h2><p class="sf-feature__text">Build tokens before pages.</p></article><article class="sf-feature"><h2 class="sf-feature__title">Delivery</h2><p class="sf-feature__text">Ship fast with fewer one-off styles.</p></article></div></section>
|
|
1521
|
+
<section class="sf-section" id="contact"><div class="sf-container sf-cta"><h2 class="sf-text-h2">Ready to scope the work?</h2><a class="sf-button" href="mailto:hello@example.com">Contact us</a></div></section>
|
|
1522
|
+
</main>`,
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
id: 'blog-index',
|
|
1526
|
+
name: 'Blog index',
|
|
1527
|
+
whenToUse: 'Editorial indexes, resources pages, changelogs, and news listings.',
|
|
1528
|
+
sections: ['sticky-navigation', 'card-grid', 'long-form-content'],
|
|
1529
|
+
classes: ['sf-breadcrumb', 'sf-section-header', 'sf-auto-grid', 'sf-card', 'sf-meta', 'sf-pagination'],
|
|
1530
|
+
keywords: ['blog', 'index', 'resources', 'articles', 'news', 'seo', 'cards'],
|
|
1531
|
+
markup: `<main class="sf-section">
|
|
1532
|
+
<div class="sf-container sf-stack">
|
|
1533
|
+
<nav class="sf-breadcrumb" aria-label="Breadcrumb"><a href="/">Home</a><span>Resources</span></nav>
|
|
1534
|
+
<header class="sf-section-header"><p class="sf-kicker">Resources</p><h1 class="sf-text-display">Ideas, guides, and product notes.</h1></header>
|
|
1535
|
+
<div class="sf-auto-grid">
|
|
1536
|
+
<article class="sf-card sf-card--interactive sf-stack"><p class="sf-meta">Guide - 6 min read</p><h2 class="sf-text-h4"><a class="sf-link-plain" href="/articles/theme-tokens">Theme tokens first</a></h2><p class="sf-text-muted">Keep brand decisions reusable.</p></article>
|
|
1537
|
+
<article class="sf-card sf-card--interactive sf-stack"><p class="sf-meta">Pattern - 4 min read</p><h2 class="sf-text-h4"><a class="sf-link-plain" href="/articles/native-ui">Native UI patterns</a></h2><p class="sf-text-muted">Use browser primitives before JavaScript.</p></article>
|
|
1538
|
+
</div>
|
|
1539
|
+
<nav class="sf-pagination" aria-label="Pagination"><a href="#">Previous</a><a aria-current="page" href="#">1</a><a href="#">2</a><a href="#">Next</a></nav>
|
|
1540
|
+
</div>
|
|
1541
|
+
</main>`,
|
|
1542
|
+
},
|
|
1543
|
+
{
|
|
1544
|
+
id: 'article-page',
|
|
1545
|
+
name: 'Article page',
|
|
1546
|
+
whenToUse: 'Long-form blog posts, documentation pages, legal content, and case studies.',
|
|
1547
|
+
sections: ['long-form-content'],
|
|
1548
|
+
classes: ['sf-breadcrumb', 'sf-prose', 'sf-prose--blog', 'sf-meta', 'sf-figure', 'sf-caption', 'sf-table-wrap'],
|
|
1549
|
+
keywords: ['article', 'post', 'prose', 'legal', 'documentation', 'case', 'study', 'long-form'],
|
|
1550
|
+
markup: `<main class="sf-section">
|
|
1551
|
+
<article class="sf-container sf-prose sf-prose--blog">
|
|
1552
|
+
<nav class="sf-breadcrumb" aria-label="Breadcrumb"><a href="/">Home</a><a href="/articles">Articles</a><span>Theme tokens</span></nav>
|
|
1553
|
+
<p class="sf-meta">Guide - 6 min read - Updated today</p>
|
|
1554
|
+
<h1>Theme tokens before page CSS</h1>
|
|
1555
|
+
<p class="sf-text-lead">Synced Flow works best when repeated brand decisions live in config.</p>
|
|
1556
|
+
<figure class="sf-figure"><div class="sf-frame"></div><figcaption class="sf-caption">Use real project imagery here.</figcaption></figure>
|
|
1557
|
+
<h2>Start with primitives</h2>
|
|
1558
|
+
<p>Use semantic colours, fluid spacing, and named components before adding custom CSS.</p>
|
|
1559
|
+
<div class="sf-table-wrap"><table><thead><tr><th>Layer</th><th>Use</th></tr></thead><tbody><tr><td>Tokens</td><td>Brand decisions</td></tr><tr><td>Components</td><td>Common UI</td></tr></tbody></table></div>
|
|
1560
|
+
</article>
|
|
1561
|
+
</main>`,
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
id: 'about-timeline',
|
|
1565
|
+
name: 'About and timeline',
|
|
1566
|
+
whenToUse: 'About pages, company stories, roadmaps, and chronological proof sections.',
|
|
1567
|
+
sections: ['hero-split', 'card-grid'],
|
|
1568
|
+
classes: ['sf-section', 'sf-container', 'sf-split', 'sf-stack', 'sf-card', 'sf-meta'],
|
|
1569
|
+
keywords: ['about', 'timeline', 'history', 'roadmap', 'company', 'story'],
|
|
1570
|
+
markup: `<main>
|
|
1571
|
+
<section class="sf-section"><div class="sf-container sf-split"><div class="sf-stack"><p class="sf-kicker">About</p><h1 class="sf-text-display">Built for teams who want fast, modern CSS.</h1></div><p class="sf-text-lead sf-prose">Tell the company story with clear type, spacing, and surfaces.</p></div></section>
|
|
1572
|
+
<section class="sf-section"><div class="sf-container sf-stack"><h2 class="sf-text-h2">Timeline</h2><div class="sf-stack"><article class="sf-card"><p class="sf-meta">2024</p><h3>Started with fluid tokens</h3></article><article class="sf-card"><p class="sf-meta">2026</p><h3>Expanded into native-first recipes</h3></article></div></div></section>
|
|
1573
|
+
</main>`,
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
id: 'team-grid',
|
|
1577
|
+
name: 'Team grid',
|
|
1578
|
+
whenToUse: 'Team, advisors, contributors, partners, or speaker sections.',
|
|
1579
|
+
sections: ['card-grid'],
|
|
1580
|
+
classes: ['sf-section', 'sf-container', 'sf-section-header', 'sf-auto-grid', 'sf-card', 'sf-frame', 'sf-meta'],
|
|
1581
|
+
keywords: ['team', 'people', 'advisors', 'contributors', 'partners', 'speakers'],
|
|
1582
|
+
markup: `<section class="sf-section">
|
|
1583
|
+
<div class="sf-container sf-stack">
|
|
1584
|
+
<header class="sf-section-header"><p class="sf-kicker">Team</p><h2 class="sf-text-h2">People behind the work.</h2></header>
|
|
1585
|
+
<div class="sf-auto-grid">
|
|
1586
|
+
<article class="sf-card sf-stack"><div class="sf-frame"></div><h3 class="sf-text-h4">Alex Morgan</h3><p class="sf-meta">Design systems</p></article>
|
|
1587
|
+
<article class="sf-card sf-stack"><div class="sf-frame"></div><h3 class="sf-text-h4">Jamie Lee</h3><p class="sf-meta">Frontend engineering</p></article>
|
|
1588
|
+
</div>
|
|
1589
|
+
</div>
|
|
1590
|
+
</section>`,
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
id: 'contact-page',
|
|
1594
|
+
name: 'Contact page',
|
|
1595
|
+
whenToUse: 'Contact, lead capture, support, and booking pages.',
|
|
1596
|
+
sections: ['contact-form', 'faq-disclosure-tabs'],
|
|
1597
|
+
classes: ['sf-section', 'sf-container', 'sf-split', 'sf-form', 'sf-field', 'sf-input', 'sf-textarea', 'sf-disclosure'],
|
|
1598
|
+
keywords: ['contact', 'lead', 'form', 'support', 'booking', 'faq'],
|
|
1599
|
+
markup: `<main class="sf-section">
|
|
1600
|
+
<div class="sf-container sf-split">
|
|
1601
|
+
<div class="sf-stack"><p class="sf-kicker">Contact</p><h1 class="sf-text-display">Tell us what you are building.</h1><details class="sf-disclosure"><summary>When will you reply?</summary><p>Usually within one working day.</p></details></div>
|
|
1602
|
+
<form class="sf-form sf-card"><label class="sf-field"><span class="sf-label">Email</span><input class="sf-input" type="email" required /></label><label class="sf-field"><span class="sf-label">Message</span><textarea class="sf-textarea" rows="5"></textarea></label><button class="sf-button" type="submit">Send</button></form>
|
|
1603
|
+
</div>
|
|
1604
|
+
</main>`,
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
id: 'not-found',
|
|
1608
|
+
name: '404 page',
|
|
1609
|
+
whenToUse: 'Missing pages and broken routes that need a useful recovery path.',
|
|
1610
|
+
sections: ['hero-split'],
|
|
1611
|
+
classes: ['sf-section', 'sf-container', 'sf-stack', 'sf-text-display', 'sf-button', 'sf-button--outline'],
|
|
1612
|
+
keywords: ['404', 'not-found', 'error', 'missing', 'empty'],
|
|
1613
|
+
markup: `<main class="sf-section">
|
|
1614
|
+
<section class="sf-container sf-stack">
|
|
1615
|
+
<p class="sf-kicker">404</p>
|
|
1616
|
+
<h1 class="sf-text-display">This page is not available.</h1>
|
|
1617
|
+
<p class="sf-text-lead sf-prose">The link may be old, or the page may have moved.</p>
|
|
1618
|
+
<div class="sf-cluster"><a class="sf-button" href="/">Go home</a><a class="sf-button sf-button--outline" href="/contact">Contact support</a></div>
|
|
1619
|
+
</section>
|
|
1620
|
+
</main>`,
|
|
1621
|
+
},
|
|
1622
|
+
{
|
|
1623
|
+
id: 'coming-soon',
|
|
1624
|
+
name: 'Coming soon page',
|
|
1625
|
+
whenToUse: 'Pre-launch pages, waitlists, newsletter capture, and product teasers.',
|
|
1626
|
+
sections: ['hero-split', 'contact-form'],
|
|
1627
|
+
classes: ['sf-section', 'sf-container', 'sf-stack', 'sf-card', 'sf-form', 'sf-input', 'sf-button', 'sf-badge'],
|
|
1628
|
+
keywords: ['coming', 'soon', 'waitlist', 'launch', 'newsletter', 'signup'],
|
|
1629
|
+
markup: `<main class="sf-section">
|
|
1630
|
+
<section class="sf-container sf-split">
|
|
1631
|
+
<div class="sf-stack"><p class="sf-badge">Coming soon</p><h1 class="sf-text-display">A new product is almost ready.</h1><p class="sf-text-lead sf-prose">Join the list for launch updates.</p></div>
|
|
1632
|
+
<form class="sf-form sf-card"><label class="sf-field"><span class="sf-label">Email</span><input class="sf-input" type="email" placeholder="you@example.com" required /></label><button class="sf-button" type="submit">Join waitlist</button></form>
|
|
1633
|
+
</section>
|
|
1634
|
+
</main>`,
|
|
1635
|
+
},
|
|
1636
|
+
]
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function getPublicCatalog() {
|
|
1640
|
+
const tokens = getTokenSummary()
|
|
1641
|
+
return {
|
|
1642
|
+
name: '@syncedco/flow',
|
|
1643
|
+
purpose: 'A dependency-free fluid CSS system for complete modern websites using tokens, layout primitives, native components, recipes, and generated utilities.',
|
|
1644
|
+
cssFiles: ['styles.css', 'tokens.css', 'reset.css', 'base.css', 'defaults.css', 'layout.css', 'components.css', 'utilities.css'],
|
|
1645
|
+
commands: ['init', 'agents install', 'agents status', 'skill', 'build', 'watch', 'lint', 'doctor', 'tokens', 'catalog', 'suggest', 'pattern', 'recipe', 'theme init', 'theme validate'],
|
|
1646
|
+
tokens,
|
|
1647
|
+
classes: tokens.starterClasses,
|
|
1648
|
+
patterns: getPublicPatterns(),
|
|
1649
|
+
recipes: getPublicRecipes(),
|
|
1650
|
+
guardrails: [
|
|
1651
|
+
'Prefer tokens and sf-* primitives before custom CSS.',
|
|
1652
|
+
'Do not import styles.css alongside modular core layer files.',
|
|
1653
|
+
'Keep generated utility class names complete in source files.',
|
|
1654
|
+
'Use theme config for repeated brand choices.',
|
|
1655
|
+
'Keep JavaScript optional; native components are styled, not shipped as JS widgets.',
|
|
1656
|
+
],
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function runTokens() {
|
|
1661
|
+
const tokenSummary = getTokenSummary()
|
|
1662
|
+
|
|
1663
|
+
if (args.includes('--json')) {
|
|
1664
|
+
console.log(JSON.stringify(tokenSummary, null, 2))
|
|
1665
|
+
return
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
console.log('Synced Flow tokens')
|
|
1669
|
+
console.log('')
|
|
1670
|
+
console.log(`Themes: ${tokenSummary.themePresets.join(', ')}`)
|
|
1671
|
+
console.log(`Type: sf-step-${fluidConfig.typeMinStep} ... sf-step-${fluidConfig.typeMaxStep}`)
|
|
1672
|
+
console.log(`Space: ${tokenSummary.space.map((name) => `sf-space-${name}`).join(', ')}`)
|
|
1673
|
+
console.log(`Semantic colours: ${tokenSummary.colours.semantic.map((name) => `sf-colour-${cssTokenName(name)}`).join(', ')}`)
|
|
1674
|
+
console.log(`Layout classes: ${tokenSummary.starterClasses.layout.join(', ')}`)
|
|
1675
|
+
console.log(`Component classes: ${tokenSummary.starterClasses.components.join(', ')}`)
|
|
1676
|
+
console.log('')
|
|
1677
|
+
console.log('Use --json for a machine-readable token map.')
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function runCatalog() {
|
|
1681
|
+
const catalog = getPublicCatalog()
|
|
1682
|
+
if (args.includes('--json')) {
|
|
1683
|
+
console.log(JSON.stringify(catalog, null, 2))
|
|
1684
|
+
return
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
console.log('Synced Flow catalog')
|
|
1688
|
+
console.log('')
|
|
1689
|
+
console.log(catalog.purpose)
|
|
1690
|
+
console.log('')
|
|
1691
|
+
console.log(`Commands: ${catalog.commands.join(', ')}`)
|
|
1692
|
+
console.log(`Patterns: ${catalog.patterns.map((pattern) => pattern.id).join(', ')}`)
|
|
1693
|
+
console.log(`Recipes: ${catalog.recipes.map((recipe) => recipe.id).join(', ')}`)
|
|
1694
|
+
console.log('')
|
|
1695
|
+
console.log('Use --json for the full machine-readable API catalog.')
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function runSuggest() {
|
|
1699
|
+
const json = args.includes('--json')
|
|
1700
|
+
const limit = Number(readOption('limit', '3'))
|
|
1701
|
+
const query = readPositionals().join(' ').trim()
|
|
1702
|
+
const scaffold = args.includes('--scaffold')
|
|
1703
|
+
if (!query) {
|
|
1704
|
+
console.error('Pass a short brief, for example: synced-flow suggest "full page scroll portfolio"')
|
|
1705
|
+
process.exit(1)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
const terms = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
|
|
1709
|
+
const scored = scoreCatalogItems(getPublicPatterns(), terms, limit)
|
|
1710
|
+
const recipeMatches = scoreCatalogItems(getPublicRecipes(), terms, limit)
|
|
1711
|
+
|
|
1712
|
+
if (scaffold) {
|
|
1713
|
+
runSuggestScaffold(query, recipeMatches, scored)
|
|
1714
|
+
return
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
if (json) {
|
|
1718
|
+
console.log(JSON.stringify({ query, suggestions: scored, recipes: recipeMatches }, null, 2))
|
|
1719
|
+
return
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
if (!scored.length && !recipeMatches.length) {
|
|
1723
|
+
console.log('No close recipe match found. Run synced-flow catalog --json to inspect available patterns.')
|
|
1724
|
+
return
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
console.log(`Suggestions for "${query}"`)
|
|
1728
|
+
if (recipeMatches.length) {
|
|
1729
|
+
console.log('')
|
|
1730
|
+
console.log('Recipes:')
|
|
1731
|
+
for (const recipe of recipeMatches) {
|
|
1732
|
+
console.log(`- ${recipe.id}: ${recipe.name}`)
|
|
1733
|
+
console.log(` Use: ${recipe.whenToUse}`)
|
|
1734
|
+
console.log(` Command: synced-flow recipe ${recipe.id}`)
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
if (scored.length) {
|
|
1738
|
+
console.log('')
|
|
1739
|
+
console.log('Patterns:')
|
|
1740
|
+
for (const pattern of scored) {
|
|
1741
|
+
console.log(`- ${pattern.id}: ${pattern.name}`)
|
|
1742
|
+
console.log(` Classes: ${pattern.classes.join(' ')}`)
|
|
1743
|
+
console.log(` Markup: ${patternMarkupFor(pattern, 'html')}`)
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function runPattern() {
|
|
1749
|
+
const patterns = getPublicPatterns()
|
|
1750
|
+
const framework = readOption('framework', 'html')
|
|
1751
|
+
const id = readPositionals()[0]
|
|
1752
|
+
|
|
1753
|
+
if (args.includes('--list') || !id) {
|
|
1754
|
+
if (args.includes('--json')) {
|
|
1755
|
+
console.log(JSON.stringify(patterns, null, 2))
|
|
1756
|
+
return
|
|
1757
|
+
}
|
|
1758
|
+
console.log('Synced Flow patterns')
|
|
1759
|
+
for (const pattern of patterns) console.log(`- ${pattern.id}: ${pattern.name}`)
|
|
1760
|
+
console.log('')
|
|
1761
|
+
console.log('Run synced-flow pattern <id> --markup to print copy-ready markup.')
|
|
1762
|
+
return
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
const pattern = findPattern(id, patterns)
|
|
1766
|
+
if (!pattern) {
|
|
1767
|
+
console.error(`Unknown pattern "${id}". Use one of: ${patterns.map((entry) => entry.id).join(', ')}.`)
|
|
1768
|
+
process.exit(1)
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const markup = renderMarkup(patternMarkupFor(pattern, framework), framework, pattern.name)
|
|
1772
|
+
if (args.includes('--json')) {
|
|
1773
|
+
console.log(JSON.stringify({ ...pattern, framework, renderedMarkup: markup }, null, 2))
|
|
1774
|
+
return
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
console.log(`${pattern.id}: ${pattern.name}`)
|
|
1778
|
+
console.log(`Use: ${pattern.whenToUse}`)
|
|
1779
|
+
console.log(`Classes: ${pattern.classes.join(' ')}`)
|
|
1780
|
+
if (pattern.requiresJs !== undefined) console.log(`Requires JS: ${pattern.requiresJs ? 'yes' : 'no'}`)
|
|
1781
|
+
if (pattern.requiresJsNotes) console.log(`Notes: ${pattern.requiresJsNotes}`)
|
|
1782
|
+
if (args.includes('--markup')) {
|
|
1783
|
+
console.log('')
|
|
1784
|
+
console.log(markup)
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function patternMarkupFor(pattern, framework = 'html') {
|
|
1789
|
+
if (typeof pattern.markup === 'string') return pattern.markup
|
|
1790
|
+
return pattern.markup?.[framework] ?? pattern.markup?.html ?? ''
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function runSuggestScaffold(query, recipeMatches, patternMatches) {
|
|
1794
|
+
const framework = readOption('framework', 'plain')
|
|
1795
|
+
const targetCwd = resolve(readOption('out', process.cwd()))
|
|
1796
|
+
const dryRun = args.includes('--dry-run')
|
|
1797
|
+
const force = args.includes('--force')
|
|
1798
|
+
const recipe = recipeMatches[0] ?? getPublicRecipes()[0]
|
|
1799
|
+
const files = scaffoldFiles({ query, framework, recipe, patterns: patternMatches })
|
|
1800
|
+
|
|
1801
|
+
if (!files.length) {
|
|
1802
|
+
console.error(`Unknown scaffold framework "${framework}". Use next, vite, astro, or plain.`)
|
|
1803
|
+
process.exit(1)
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
console.log(`Scaffold for "${query}" (${framework})`)
|
|
1807
|
+
console.log('')
|
|
1808
|
+
console.log('Files:')
|
|
1809
|
+
for (const file of files) console.log(`- ${file.path}`)
|
|
1810
|
+
|
|
1811
|
+
if (dryRun) {
|
|
1812
|
+
for (const file of files) {
|
|
1813
|
+
console.log('')
|
|
1814
|
+
console.log(`--- ${file.path} ---`)
|
|
1815
|
+
console.log(file.contents.trimEnd())
|
|
1816
|
+
}
|
|
1817
|
+
console.log('')
|
|
1818
|
+
console.log('dry-run no files were changed.')
|
|
1819
|
+
return
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
for (const file of files) writeScaffoldFile(resolve(targetCwd, file.path), file.contents, force)
|
|
1823
|
+
addPackageScripts(targetCwd)
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function scaffoldFiles({ query, framework, recipe, patterns }) {
|
|
1827
|
+
const safeRecipe = recipe ?? getPublicRecipes()[0]
|
|
1828
|
+
const htmlMarkup = safeRecipe.markup
|
|
1829
|
+
const config = `import { defineConfig } from '@syncedco/flow/config'
|
|
1830
|
+
import { themePresets } from '@syncedco/flow/presets'
|
|
1831
|
+
|
|
1832
|
+
export default defineConfig({
|
|
1833
|
+
scan: ${framework === 'next' ? "['src/app', 'src/components']" : framework === 'astro' ? "['src', 'components']" : framework === 'plain' ? "['.']" : "['src', 'components']"},
|
|
1834
|
+
out: '${framework === 'next' ? 'src/app/synced-flow.generated.css' : framework === 'astro' ? 'src/styles/synced-flow.generated.css' : framework === 'plain' ? 'synced-flow.generated.css' : 'src/synced-flow.generated.css'}',
|
|
1835
|
+
theme: themePresets.synced,
|
|
1836
|
+
})
|
|
1837
|
+
`
|
|
1838
|
+
const matchedComment = `Matched recipe: ${safeRecipe.id}. Matched patterns: ${patterns.map((pattern) => pattern.id).join(', ') || 'none'}. Brief: ${query}.`
|
|
1839
|
+
|
|
1840
|
+
if (framework === 'next') {
|
|
1841
|
+
return [
|
|
1842
|
+
{ path: 'synced-flow.config.mjs', contents: config },
|
|
1843
|
+
{
|
|
1844
|
+
path: 'src/app/synced-flow.css',
|
|
1845
|
+
contents: `@import "@syncedco/flow/styles.css";
|
|
1846
|
+
@import "@syncedco/flow/defaults.css";
|
|
1847
|
+
@import "./synced-flow.generated.css";
|
|
1848
|
+
`,
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
path: 'src/app/layout.tsx',
|
|
1852
|
+
contents: `import './synced-flow.css'
|
|
1853
|
+
|
|
1854
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1855
|
+
return (
|
|
1856
|
+
<html lang="en">
|
|
1857
|
+
<body>{children}</body>
|
|
1858
|
+
</html>
|
|
1859
|
+
)
|
|
1860
|
+
}
|
|
1861
|
+
`,
|
|
1862
|
+
},
|
|
1863
|
+
{
|
|
1864
|
+
path: 'src/app/page.tsx',
|
|
1865
|
+
contents: `// ${matchedComment}
|
|
1866
|
+
${renderMarkup(htmlMarkup, 'next', safeRecipe.name)}
|
|
1867
|
+
`,
|
|
1868
|
+
},
|
|
1869
|
+
]
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
if (framework === 'astro') {
|
|
1873
|
+
return [
|
|
1874
|
+
{ path: 'synced-flow.config.mjs', contents: config },
|
|
1875
|
+
{
|
|
1876
|
+
path: 'src/styles/synced-flow.css',
|
|
1877
|
+
contents: `@import "@syncedco/flow/styles.css";
|
|
1878
|
+
@import "@syncedco/flow/defaults.css";
|
|
1879
|
+
@import "./synced-flow.generated.css";
|
|
1880
|
+
`,
|
|
1881
|
+
},
|
|
1882
|
+
{
|
|
1883
|
+
path: 'src/pages/index.astro',
|
|
1884
|
+
contents: `---
|
|
1885
|
+
import '../styles/synced-flow.css'
|
|
1886
|
+
---
|
|
1887
|
+
<!-- ${matchedComment} -->
|
|
1888
|
+
${htmlMarkup}
|
|
1889
|
+
`,
|
|
1890
|
+
},
|
|
1891
|
+
]
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
if (framework === 'vite') {
|
|
1895
|
+
return [
|
|
1896
|
+
{ path: 'synced-flow.config.mjs', contents: config },
|
|
1897
|
+
{
|
|
1898
|
+
path: 'src/synced-flow.css',
|
|
1899
|
+
contents: `@import "@syncedco/flow/styles.css";
|
|
1900
|
+
@import "@syncedco/flow/defaults.css";
|
|
1901
|
+
@import "./synced-flow.generated.css";
|
|
1902
|
+
`,
|
|
1903
|
+
},
|
|
1904
|
+
{
|
|
1905
|
+
path: 'src/main.jsx',
|
|
1906
|
+
contents: `import './synced-flow.css'
|
|
1907
|
+
|
|
1908
|
+
document.querySelector('#app').innerHTML = \`${htmlMarkup.replace(/`/g, '\\`')}\`
|
|
1909
|
+
`,
|
|
1910
|
+
},
|
|
1911
|
+
]
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (framework === 'plain' || framework === 'html') {
|
|
1915
|
+
return [
|
|
1916
|
+
{ path: 'synced-flow.config.mjs', contents: config },
|
|
1917
|
+
{
|
|
1918
|
+
path: 'synced-flow.css',
|
|
1919
|
+
contents: `@import "@syncedco/flow/styles.css";
|
|
1920
|
+
@import "@syncedco/flow/defaults.css";
|
|
1921
|
+
@import "./synced-flow.generated.css";
|
|
1922
|
+
`,
|
|
1923
|
+
},
|
|
1924
|
+
{
|
|
1925
|
+
path: 'index.html',
|
|
1926
|
+
contents: `<!doctype html>
|
|
1927
|
+
<html lang="en">
|
|
1928
|
+
<head>
|
|
1929
|
+
<meta charset="utf-8" />
|
|
1930
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1931
|
+
<title>Synced Flow starter</title>
|
|
1932
|
+
<link rel="stylesheet" href="./synced-flow.css" />
|
|
1933
|
+
</head>
|
|
1934
|
+
<body>
|
|
1935
|
+
<!-- ${matchedComment} -->
|
|
1936
|
+
${indentMarkup(htmlMarkup, 4)}
|
|
1937
|
+
</body>
|
|
1938
|
+
</html>
|
|
1939
|
+
`,
|
|
1940
|
+
},
|
|
1941
|
+
]
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
return []
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function writeScaffoldFile(file, contents, force) {
|
|
1948
|
+
if (existsSync(file) && !force) {
|
|
1949
|
+
console.log(`skip ${relative(process.cwd(), file)} already exists. Use --force to overwrite.`)
|
|
1950
|
+
return
|
|
1951
|
+
}
|
|
1952
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
1953
|
+
writeFileSync(file, contents)
|
|
1954
|
+
console.log(`write ${relative(process.cwd(), file)}`)
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
function scoreCatalogItems(items, terms, limit) {
|
|
1958
|
+
return items
|
|
1959
|
+
.map((item) => {
|
|
1960
|
+
const haystack = [item.id, item.name, item.whenToUse, ...(item.keywords ?? []), ...(item.classes ?? []), ...(item.sections ?? [])].join(' ').toLowerCase()
|
|
1961
|
+
const score = terms.reduce((sum, term) => sum + (haystack.includes(term) ? 2 : 0) + (item.keywords ?? []).filter((keyword) => keyword.includes(term)).length, 0)
|
|
1962
|
+
return { ...item, score }
|
|
1963
|
+
})
|
|
1964
|
+
.filter((item) => item.score > 0)
|
|
1965
|
+
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
|
1966
|
+
.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 3)
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function runRecipe() {
|
|
1970
|
+
const id = readPositionals()[0]
|
|
1971
|
+
const framework = readOption('framework', 'html')
|
|
1972
|
+
const section = readOption('section')
|
|
1973
|
+
const recipes = getPublicRecipes()
|
|
1974
|
+
const patterns = getPublicPatterns()
|
|
1975
|
+
|
|
1976
|
+
if (section) {
|
|
1977
|
+
const pattern = findPattern(section, patterns)
|
|
1978
|
+
if (!pattern) {
|
|
1979
|
+
console.error(`Unknown section "${section}". Use one of: ${patterns.map((entry) => entry.id).join(', ')}.`)
|
|
1980
|
+
process.exit(1)
|
|
1981
|
+
}
|
|
1982
|
+
const markup = renderMarkup(patternMarkupFor(pattern, framework), framework, pattern.name)
|
|
1983
|
+
if (args.includes('--json')) {
|
|
1984
|
+
console.log(JSON.stringify({ ...pattern, framework, renderedMarkup: markup }, null, 2))
|
|
1985
|
+
return
|
|
1986
|
+
}
|
|
1987
|
+
console.log(`${pattern.id}: ${pattern.name}`)
|
|
1988
|
+
console.log(`Use: ${pattern.whenToUse}`)
|
|
1989
|
+
console.log(`Classes: ${pattern.classes.join(' ')}`)
|
|
1990
|
+
if (args.includes('--markup')) {
|
|
1991
|
+
console.log('')
|
|
1992
|
+
console.log(markup)
|
|
1993
|
+
}
|
|
1994
|
+
return
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
if (args.includes('--json')) {
|
|
1998
|
+
const payload = id ? recipes.find((recipe) => recipe.id === id) : recipes
|
|
1999
|
+
if (!payload) {
|
|
2000
|
+
console.error(`Unknown recipe "${id}". Use one of: ${recipes.map((recipe) => recipe.id).join(', ')}.`)
|
|
2001
|
+
process.exit(1)
|
|
2002
|
+
}
|
|
2003
|
+
if (Array.isArray(payload)) console.log(JSON.stringify(payload, null, 2))
|
|
2004
|
+
else console.log(JSON.stringify({ ...payload, framework, renderedMarkup: renderMarkup(payload.markup, framework, payload.name) }, null, 2))
|
|
2005
|
+
return
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
if (!id) {
|
|
2009
|
+
console.log('Synced Flow recipes')
|
|
2010
|
+
for (const recipe of recipes) {
|
|
2011
|
+
console.log(`- ${recipe.id}: ${recipe.name}`)
|
|
2012
|
+
}
|
|
2013
|
+
console.log('')
|
|
2014
|
+
console.log('Run synced-flow recipe <id> --markup to print copy-ready HTML.')
|
|
2015
|
+
return
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
const recipe = recipes.find((entry) => entry.id === id)
|
|
2019
|
+
if (!recipe) {
|
|
2020
|
+
console.error(`Unknown recipe "${id}". Use one of: ${recipes.map((entry) => entry.id).join(', ')}.`)
|
|
2021
|
+
process.exit(1)
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
console.log(`${recipe.id}: ${recipe.name}`)
|
|
2025
|
+
console.log(`Use: ${recipe.whenToUse}`)
|
|
2026
|
+
console.log(`Sections: ${recipe.sections.join(', ')}`)
|
|
2027
|
+
console.log(`Classes: ${recipe.classes.join(' ')}`)
|
|
2028
|
+
console.log(`Framework: ${framework}`)
|
|
2029
|
+
if (args.includes('--markup')) {
|
|
2030
|
+
console.log('')
|
|
2031
|
+
console.log(renderMarkup(recipe.markup, framework, recipe.name))
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function findPattern(value, patterns) {
|
|
2036
|
+
const normal = value.toLowerCase()
|
|
2037
|
+
return (
|
|
2038
|
+
patterns.find((pattern) => pattern.id === normal) ??
|
|
2039
|
+
patterns.find((pattern) => pattern.keywords.some((keyword) => keyword === normal)) ??
|
|
2040
|
+
patterns.find((pattern) => pattern.name.toLowerCase().includes(normal))
|
|
2041
|
+
)
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function renderMarkup(markup, framework, name) {
|
|
2045
|
+
if (framework === 'html' || framework === 'astro-html') return markup
|
|
2046
|
+
if (framework === 'astro') {
|
|
2047
|
+
return `---
|
|
2048
|
+
import '../styles/synced-flow.css'
|
|
2049
|
+
---
|
|
2050
|
+
|
|
2051
|
+
${markup}`
|
|
2052
|
+
}
|
|
2053
|
+
if (framework === 'next' || framework === 'react' || framework === 'jsx') {
|
|
2054
|
+
if (/export\s+default\s+function/.test(markup)) return markup
|
|
2055
|
+
return `export default function Page() {
|
|
2056
|
+
return (
|
|
2057
|
+
<>
|
|
2058
|
+
${indentMarkup(htmlToJsx(markup), 6)}
|
|
2059
|
+
</>
|
|
2060
|
+
)
|
|
2061
|
+
}`
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
console.error(`Unknown framework "${framework}". Use html, next, react, or astro.`)
|
|
2065
|
+
process.exit(1)
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function htmlToJsx(markup) {
|
|
2069
|
+
return markup
|
|
2070
|
+
.replace(/\bclass=/g, 'className=')
|
|
2071
|
+
.replace(/\bfor=/g, 'htmlFor=')
|
|
2072
|
+
.replace(/\bautocomplete=/g, 'autoComplete=')
|
|
2073
|
+
.replace(/\breadonly=/g, 'readOnly=')
|
|
2074
|
+
.replace(/\btabindex=/g, 'tabIndex=')
|
|
2075
|
+
.replace(/\bformaction=/g, 'formAction=')
|
|
2076
|
+
.replace(/\bpopoverTarget=/g, 'popoverTarget=')
|
|
2077
|
+
.replace(/\bpopovertarget=/g, 'popoverTarget=')
|
|
2078
|
+
.replace(/\bpopoverTargetAction=/g, 'popoverTargetAction=')
|
|
2079
|
+
.replace(/\bpopovertargetaction=/g, 'popoverTargetAction=')
|
|
2080
|
+
.replace(/\bcommandfor=/g, 'commandFor=')
|
|
2081
|
+
.replace(/\bstroke-linecap=/g, 'strokeLinecap=')
|
|
2082
|
+
.replace(/\bstroke-linejoin=/g, 'strokeLinejoin=')
|
|
2083
|
+
.replace(/\bstroke-width=/g, 'strokeWidth=')
|
|
2084
|
+
.replace(/<!--/g, '{/*')
|
|
2085
|
+
.replace(/-->/g, '*/}')
|
|
2086
|
+
.replace(/<input([^>/]*?)>/g, '<input$1 />')
|
|
2087
|
+
.replace(/<br>/g, '<br />')
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function indentMarkup(markup, spaces) {
|
|
2091
|
+
const pad = ' '.repeat(spaces)
|
|
2092
|
+
return markup.split('\n').map((line) => `${pad}${line}`).join('\n')
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
async function runThemeCommand() {
|
|
2096
|
+
const subcommand = args.shift()
|
|
2097
|
+
if (subcommand === 'init') return runThemeInit()
|
|
2098
|
+
if (subcommand === 'validate') return runThemeValidate()
|
|
2099
|
+
|
|
2100
|
+
console.error('Unknown theme command. Use: synced-flow theme init --from brief.md or synced-flow theme validate')
|
|
2101
|
+
return 1
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function runThemeInit() {
|
|
2105
|
+
const targetCwd = resolve(readOption('cwd', process.cwd()))
|
|
2106
|
+
const from = readOption('from')
|
|
2107
|
+
const presetBase = normalizeThemeName(readOption('preset-base', 'synced'))
|
|
2108
|
+
if (!themePresets[presetBase]) {
|
|
2109
|
+
console.error(`Unknown preset base "${readOption('preset-base')}". Use one of: ${presetNames.map(kebabThemeName).join(', ')}.`)
|
|
2110
|
+
return 1
|
|
2111
|
+
}
|
|
2112
|
+
if (!from) {
|
|
2113
|
+
console.error('Pass a theme brief with --from <file>, for example: synced-flow theme init --from brief.md')
|
|
2114
|
+
return 1
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
const briefFile = resolve(targetCwd, from)
|
|
2118
|
+
if (!existsSync(briefFile)) {
|
|
2119
|
+
console.error(`Theme brief not found: ${relative(targetCwd, briefFile)}`)
|
|
2120
|
+
return 1
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
const brief = readFileSync(briefFile, 'utf8')
|
|
2124
|
+
const analysis = analyseThemeBrief(brief)
|
|
2125
|
+
const theme = themeFromBrief(brief, presetBase)
|
|
2126
|
+
const errors = []
|
|
2127
|
+
validateTheme(theme, errors)
|
|
2128
|
+
if (errors.length) {
|
|
2129
|
+
console.error('Generated theme was invalid:')
|
|
2130
|
+
for (const error of errors) console.error(` - ${error}`)
|
|
2131
|
+
return 1
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
const summary = summarizeTheme(theme)
|
|
2135
|
+
if (args.includes('--json')) {
|
|
2136
|
+
console.log(JSON.stringify({ summary, presetBase: kebabThemeName(presetBase), warnings: analysis.warnings, theme }, null, 2))
|
|
2137
|
+
return 0
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
console.log(`Summary: ${summary}`)
|
|
2141
|
+
console.log(`Preset base: ${kebabThemeName(presetBase)}`)
|
|
2142
|
+
if (analysis.warnings.length) {
|
|
2143
|
+
console.log('')
|
|
2144
|
+
console.log('Warnings:')
|
|
2145
|
+
for (const warning of analysis.warnings) console.log(`- ${warning}`)
|
|
2146
|
+
}
|
|
2147
|
+
console.log('')
|
|
2148
|
+
console.log('Paste this into synced-flow.config.mjs:')
|
|
2149
|
+
console.log('')
|
|
2150
|
+
console.log(`theme: ${formatJsValue(theme, 0)},`)
|
|
2151
|
+
return 0
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
async function runThemeValidate() {
|
|
2155
|
+
const targetCwd = resolve(readOption('cwd', process.cwd()))
|
|
2156
|
+
const configPath = resolveConfigPath(readOption('config'), targetCwd)
|
|
2157
|
+
if (!configPath) {
|
|
2158
|
+
console.error('synced-flow.config.mjs was not found.')
|
|
2159
|
+
return 1
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
const loaded = await loadConfig(configPath)
|
|
2163
|
+
const errors = []
|
|
2164
|
+
if (loaded.theme === undefined) errors.push('theme is missing. Add themePresets.<name> or a theme object.')
|
|
2165
|
+
else validateTheme(loaded.theme, errors)
|
|
2166
|
+
|
|
2167
|
+
if (errors.length) {
|
|
2168
|
+
console.error(`Invalid Synced Flow theme in ${relative(targetCwd, configPath)}:`)
|
|
2169
|
+
for (const error of errors) console.error(` - ${error}`)
|
|
2170
|
+
return 1
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
console.log(`pass theme is valid in ${relative(targetCwd, configPath)}.`)
|
|
2174
|
+
return 0
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function analyseThemeBrief(brief) {
|
|
2178
|
+
const lower = brief.toLowerCase()
|
|
2179
|
+
const checks = [
|
|
2180
|
+
['radius', /\b(radius|rounded|square|sharp|pill|curved)\b/],
|
|
2181
|
+
['font', /\b(font|typeface|sans|serif|inter|system|editorial|display)\b/],
|
|
2182
|
+
['primary colour', /\b(primary|brand)\b.*\b(blue|green|orange|red|purple|violet|teal|cyan|pink|amber|yellow|slate|black|neutral)\b/],
|
|
2183
|
+
['accent colour', /\b(accent|secondary)\b.*\b(blue|green|orange|red|purple|violet|teal|cyan|pink|amber|yellow|slate|black|neutral)\b/],
|
|
2184
|
+
['surface style', /\b(surface|card|raised|elevated|flat|warm)\b/],
|
|
2185
|
+
['density', /\b(compact|dense|spacious|generous|open)\b/],
|
|
2186
|
+
]
|
|
2187
|
+
const warnings = checks
|
|
2188
|
+
.filter(([, pattern]) => !pattern.test(lower))
|
|
2189
|
+
.map(([label]) => `Brief does not mention ${label}; Synced Flow used a sensible default.`)
|
|
2190
|
+
return { warnings }
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function themeFromBrief(brief, presetBase = 'synced') {
|
|
2194
|
+
const lower = brief.toLowerCase()
|
|
2195
|
+
const base = structuredClone(themePresets[presetBase] ?? themePresets.synced)
|
|
2196
|
+
const primary = findNamedColour(lower, 'primary') ?? findNamedColour(lower, 'brand') ?? findAnyColour(lower) ?? base.colours?.primary ?? 'oklch(62% 0.19 252)'
|
|
2197
|
+
const accent = findNamedColour(lower, 'accent') ?? findNamedColour(lower, 'secondary') ?? base.colours?.accent ?? (primary.includes('252') ? 'oklch(68% 0.16 150)' : 'oklch(62% 0.19 252)')
|
|
2198
|
+
const radius = lower.includes('no radius') || lower.includes('square') || lower.includes('sharp') ? '0.125rem' : lower.includes('pill') || lower.includes('fully rounded') ? '999rem' : lower.includes('slightly') || lower.includes('subtle') ? '0.5rem' : base.components?.button?.radius ?? '0.875rem'
|
|
2199
|
+
const compact = lower.includes('compact') || lower.includes('dense')
|
|
2200
|
+
const spacious = lower.includes('spacious') || lower.includes('generous') || lower.includes('open')
|
|
2201
|
+
const raised = lower.includes('raised') || lower.includes('elevated') || lower.includes('shadow')
|
|
2202
|
+
const flat = lower.includes('flat')
|
|
2203
|
+
const serif = lower.includes('serif') || lower.includes('editorial')
|
|
2204
|
+
const mono = lower.includes('mono') || lower.includes('developer')
|
|
2205
|
+
const warm = lower.includes('warm')
|
|
2206
|
+
|
|
2207
|
+
return mergeTheme(base, {
|
|
2208
|
+
fonts: {
|
|
2209
|
+
sans: lower.includes('inter') ? 'Inter, ui-sans-serif, system-ui, sans-serif' : base.fonts?.sans ?? 'ui-sans-serif, system-ui, sans-serif',
|
|
2210
|
+
display: serif ? 'Georgia, ui-serif, serif' : base.fonts?.display ?? 'var(--sf-font-sans)',
|
|
2211
|
+
...(mono ? { mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } : {}),
|
|
2212
|
+
},
|
|
2213
|
+
colours: {
|
|
2214
|
+
primary,
|
|
2215
|
+
link: primary,
|
|
2216
|
+
ring: primary,
|
|
2217
|
+
accent,
|
|
2218
|
+
surface: warm ? 'oklch(97% 0.018 72)' : base.colours?.surface ?? 'oklch(98% 0.004 248)',
|
|
2219
|
+
'surface-alt': warm ? 'oklch(94% 0.024 72)' : base.colours?.surfaceAlt ?? base.colours?.['surface-alt'] ?? 'oklch(95% 0.006 248)',
|
|
2220
|
+
},
|
|
2221
|
+
darkColours: {
|
|
2222
|
+
primary: colorForDarkMode(primary),
|
|
2223
|
+
link: colorForDarkMode(primary),
|
|
2224
|
+
ring: colorForDarkMode(primary),
|
|
2225
|
+
accent: colorForDarkMode(accent),
|
|
2226
|
+
},
|
|
2227
|
+
radii: {
|
|
2228
|
+
md: radius,
|
|
2229
|
+
lg: radius,
|
|
2230
|
+
xl: radius === '999rem' ? '1.75rem' : `calc(${radius} * 1.4)`,
|
|
2231
|
+
},
|
|
2232
|
+
layout: {
|
|
2233
|
+
containerMax: lower.includes('wide') ? '82rem' : lower.includes('narrow') ? '64rem' : base.layout?.containerMax ?? '72rem',
|
|
2234
|
+
gutter: compact ? 'var(--space-xs-s)' : spacious ? 'var(--space-m-xl)' : base.layout?.gutter ?? 'var(--space-s-l)',
|
|
2235
|
+
},
|
|
2236
|
+
components: {
|
|
2237
|
+
button: {
|
|
2238
|
+
radius,
|
|
2239
|
+
blockSize: compact ? '2.5rem' : spacious ? '3rem' : '2.75rem',
|
|
2240
|
+
paddingInline: compact ? 'var(--space-xs)' : 'var(--space-s)',
|
|
2241
|
+
},
|
|
2242
|
+
card: {
|
|
2243
|
+
radius: radius === '999rem' ? '1.5rem' : `calc(${radius} * 1.4)`,
|
|
2244
|
+
padding: compact ? 'var(--space-s)' : spacious ? 'var(--space-l)' : 'var(--space-m-l)',
|
|
2245
|
+
...(raised ? { shadow: 'var(--shadow-md)' } : {}),
|
|
2246
|
+
...(flat ? { shadow: 'none' } : {}),
|
|
2247
|
+
},
|
|
2248
|
+
input: {
|
|
2249
|
+
radius,
|
|
2250
|
+
blockSize: compact ? '2.5rem' : '2.75rem',
|
|
2251
|
+
},
|
|
2252
|
+
},
|
|
2253
|
+
})
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
function mergeTheme(base, overrides) {
|
|
2257
|
+
const output = structuredClone(base)
|
|
2258
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
2259
|
+
if (isPlainObject(value) && isPlainObject(output[key])) output[key] = mergeTheme(output[key], value)
|
|
2260
|
+
else output[key] = value
|
|
2261
|
+
}
|
|
2262
|
+
return output
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
function findNamedColour(brief, label) {
|
|
2266
|
+
const segment = brief.match(new RegExp(`${label}[^\\n.;,]*(blue|green|orange|red|purple|violet|teal|cyan|pink|amber|yellow|slate|black|neutral)`))
|
|
2267
|
+
return segment ? colourFromName(segment[1]) : null
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function findAnyColour(brief) {
|
|
2271
|
+
const match = brief.match(/\b(blue|green|orange|red|purple|violet|teal|cyan|pink|amber|yellow|slate|black|neutral)\b/)
|
|
2272
|
+
return match ? colourFromName(match[1]) : null
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function colourFromName(name) {
|
|
2276
|
+
return {
|
|
2277
|
+
blue: 'oklch(62% 0.19 252)',
|
|
2278
|
+
green: 'oklch(62% 0.16 150)',
|
|
2279
|
+
orange: 'oklch(69% 0.18 44)',
|
|
2280
|
+
red: 'oklch(58% 0.22 26)',
|
|
2281
|
+
purple: 'oklch(58% 0.2 292)',
|
|
2282
|
+
violet: 'oklch(58% 0.2 292)',
|
|
2283
|
+
teal: 'oklch(64% 0.13 186)',
|
|
2284
|
+
cyan: 'oklch(70% 0.13 210)',
|
|
2285
|
+
pink: 'oklch(64% 0.2 8)',
|
|
2286
|
+
amber: 'oklch(72% 0.16 70)',
|
|
2287
|
+
yellow: 'oklch(80% 0.15 92)',
|
|
2288
|
+
slate: 'oklch(44% 0.04 258)',
|
|
2289
|
+
black: 'oklch(22% 0.02 258)',
|
|
2290
|
+
neutral: 'oklch(48% 0.02 248)',
|
|
2291
|
+
}[name]
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function colorForDarkMode(value) {
|
|
2295
|
+
if (value.includes('252')) return 'oklch(72% 0.14 252)'
|
|
2296
|
+
if (value.includes('150')) return 'oklch(76% 0.13 150)'
|
|
2297
|
+
if (value.includes('44')) return 'oklch(76% 0.14 44)'
|
|
2298
|
+
if (value.includes('292')) return 'oklch(74% 0.14 292)'
|
|
2299
|
+
return value
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function summarizeTheme(theme) {
|
|
2303
|
+
const displayFont = theme.fonts.display.toLowerCase()
|
|
2304
|
+
const displayLabel = /\b(georgia|fraunces|ui-serif)\b/.test(displayFont) ? 'serif display' : 'system display'
|
|
2305
|
+
return `${theme.radii.md} radius, ${displayLabel}, ${theme.colours.primary} primary, ${theme.colours.accent} accent, ${theme.layout.containerMax} container.`
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function formatJsValue(value, indent = 0) {
|
|
2309
|
+
const pad = ' '.repeat(indent)
|
|
2310
|
+
const nextPad = ' '.repeat(indent + 2)
|
|
2311
|
+
if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`
|
|
2312
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
2313
|
+
if (Array.isArray(value)) return `[${value.map((item) => formatJsValue(item, indent)).join(', ')}]`
|
|
2314
|
+
if (isPlainObject(value)) {
|
|
2315
|
+
const entries = Object.entries(value)
|
|
2316
|
+
if (!entries.length) return '{}'
|
|
2317
|
+
const lines = entries.map(([key, nested]) => `${nextPad}${/^[a-zA-Z_$][\w$]*$/.test(key) ? key : `'${key}'`}: ${formatJsValue(nested, indent + 2)},`)
|
|
2318
|
+
return `{\n${lines.join('\n')}\n${pad}}`
|
|
2319
|
+
}
|
|
2320
|
+
return 'undefined'
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function detectPreset(targetCwd) {
|
|
2324
|
+
if (existsSync(resolve(targetCwd, 'next.config.js')) || existsSync(resolve(targetCwd, 'next.config.mjs')) || existsSync(resolve(targetCwd, 'next.config.ts'))) return 'next'
|
|
2325
|
+
if (existsSync(resolve(targetCwd, 'astro.config.mjs')) || existsSync(resolve(targetCwd, 'astro.config.ts'))) return 'astro'
|
|
2326
|
+
if (existsSync(resolve(targetCwd, 'vite.config.js')) || existsSync(resolve(targetCwd, 'vite.config.ts')) || existsSync(resolve(targetCwd, 'vite.config.mjs'))) return 'vite'
|
|
2327
|
+
if (existsSync(resolve(targetCwd, 'functions.php')) || existsSync(resolve(targetCwd, 'theme.json'))) return 'wordpress'
|
|
2328
|
+
if (existsSync(resolve(targetCwd, 'src'))) return 'react'
|
|
2329
|
+
return 'plain'
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
function defaultScanDirs(targetCwd, preset) {
|
|
2333
|
+
const candidates = {
|
|
2334
|
+
next: ['app', 'pages', 'components', 'lib', 'src'],
|
|
2335
|
+
vite: ['src', 'components', 'lib'],
|
|
2336
|
+
react: ['src', 'components', 'lib'],
|
|
2337
|
+
astro: ['src', 'components'],
|
|
2338
|
+
wordpress: ['templates', 'parts', 'patterns', 'blocks', 'inc', 'includes', 'src', 'assets'],
|
|
2339
|
+
plain: ['src', '.'],
|
|
2340
|
+
legacy: ['app', 'pages', 'components', 'lib', 'src'],
|
|
2341
|
+
}[preset] ?? ['src']
|
|
2342
|
+
|
|
2343
|
+
const existing = candidates.filter((dir) => existsSync(resolve(targetCwd, dir)))
|
|
2344
|
+
return existing.length ? existing : candidates.slice(0, 1)
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function defaultOutputPath(targetCwd, preset) {
|
|
2348
|
+
if (preset === 'next') {
|
|
2349
|
+
if (existsSync(resolve(targetCwd, 'app'))) return 'app/synced-flow.generated.css'
|
|
2350
|
+
if (existsSync(resolve(targetCwd, 'src/app'))) return 'src/app/synced-flow.generated.css'
|
|
2351
|
+
}
|
|
2352
|
+
if (preset === 'wordpress') return 'assets/css/synced-flow.css'
|
|
2353
|
+
if (preset === 'plain') return 'synced-flow.generated.css'
|
|
2354
|
+
return 'src/synced-flow.generated.css'
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function defaultStyleEntryPath(targetCwd, preset, outPath) {
|
|
2358
|
+
if (preset === 'next') return outPath.replace(/synced-flow\.generated\.css$/, 'synced-flow.css')
|
|
2359
|
+
if (preset === 'wordpress') return outPath
|
|
2360
|
+
if (preset === 'plain') return 'synced-flow.css'
|
|
2361
|
+
return existsSync(resolve(targetCwd, 'src')) ? 'src/synced-flow.css' : 'synced-flow.css'
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function defaultIncludeDefaults(preset) {
|
|
2365
|
+
return ['next', 'vite', 'react', 'astro', 'plain'].includes(preset)
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
function findStyleEntry(targetCwd) {
|
|
2369
|
+
const candidates = [
|
|
2370
|
+
'src/synced-flow.css',
|
|
2371
|
+
'app/synced-flow.css',
|
|
2372
|
+
'src/app/synced-flow.css',
|
|
2373
|
+
'synced-flow.css',
|
|
2374
|
+
]
|
|
2375
|
+
|
|
2376
|
+
for (const candidate of candidates) {
|
|
2377
|
+
if (existsSync(resolve(targetCwd, candidate))) return candidate
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const roots = ['app', 'src', 'pages', 'components', 'assets'].filter((dir) => existsSync(resolve(targetCwd, dir)))
|
|
2381
|
+
for (const root of roots) {
|
|
2382
|
+
const files = listProjectFiles(resolve(targetCwd, root))
|
|
2383
|
+
const match = files.find((file) => file.endsWith('.css') && readFileSync(file, 'utf8').includes('@syncedco/flow/'))
|
|
2384
|
+
if (match) return relative(targetCwd, match)
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
return null
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function writeProjectFile(file, contents, force) {
|
|
2391
|
+
if (existsSync(file) && !force) {
|
|
2392
|
+
console.log(`skip ${relative(process.cwd(), file)} already exists. Use --force to overwrite.`)
|
|
2393
|
+
return
|
|
2394
|
+
}
|
|
2395
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
2396
|
+
writeFileSync(file, contents)
|
|
2397
|
+
console.log(`write ${relative(process.cwd(), file)}`)
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function writeGeneratedCss(file, contents) {
|
|
2401
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
2402
|
+
const tempFile = `${file}.${process.pid}.tmp`
|
|
2403
|
+
writeFileSync(tempFile, contents)
|
|
2404
|
+
renameSync(tempFile, file)
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function addPackageScripts(targetCwd) {
|
|
2408
|
+
const packageFile = resolve(targetCwd, 'package.json')
|
|
2409
|
+
if (!existsSync(packageFile)) {
|
|
2410
|
+
console.log('skip package.json not found; scripts were not added.')
|
|
2411
|
+
return
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
|
|
2415
|
+
pkg.scripts ??= {}
|
|
2416
|
+
pkg.scripts['flow:build'] ??= 'synced-flow build'
|
|
2417
|
+
pkg.scripts['flow:check'] ??= 'synced-flow build --check'
|
|
2418
|
+
pkg.scripts['flow:doctor'] ??= 'synced-flow doctor'
|
|
2419
|
+
pkg.scripts['flow:lint'] ??= 'synced-flow lint'
|
|
2420
|
+
pkg.scripts['flow:watch'] ??= 'synced-flow watch'
|
|
2421
|
+
writeFileSync(packageFile, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
2422
|
+
console.log('update package.json scripts')
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
function formatStringArray(values) {
|
|
2426
|
+
return `[${values.map((value) => `'${value}'`).join(', ')}]`
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function projectContains(targetCwd, needle) {
|
|
2430
|
+
return allProjectFiles(targetCwd).some((file) => readFileSync(file, 'utf8').includes(needle))
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function allProjectFiles(targetCwd) {
|
|
2434
|
+
const rootFiles = ['synced-flow.css', 'styles.css', 'style.css', 'app.css', 'global.css', 'globals.css']
|
|
2435
|
+
.map((file) => resolve(targetCwd, file))
|
|
2436
|
+
.filter((file) => existsSync(file))
|
|
2437
|
+
|
|
2438
|
+
const roots = ['app', 'src', 'pages', 'components', 'templates', 'parts', 'patterns', 'blocks', 'inc', 'includes', 'assets'].filter((dir) => existsSync(resolve(targetCwd, dir)))
|
|
2439
|
+
return [...rootFiles, ...roots.flatMap((root) => listProjectFiles(resolve(targetCwd, root)))]
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function findDuplicateCoreImports(targetCwd) {
|
|
2443
|
+
const modularCoreImports = ['tokens.css', 'reset.css', 'base.css', 'layout.css', 'components.css', 'utilities.css']
|
|
2444
|
+
return allProjectFiles(targetCwd)
|
|
2445
|
+
.filter((file) => file.endsWith('.css'))
|
|
2446
|
+
.filter((file) => {
|
|
2447
|
+
const css = readFileSync(file, 'utf8')
|
|
2448
|
+
return css.includes('@syncedco/flow/styles.css') && modularCoreImports.some((name) => css.includes(`@syncedco/flow/${name}`))
|
|
2449
|
+
})
|
|
2450
|
+
.map((file) => relative(targetCwd, file))
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function findCustomTokenOverrides(targetCwd, generatedOut) {
|
|
2454
|
+
const generatedFile = generatedOut ? resolve(targetCwd, generatedOut) : null
|
|
2455
|
+
return allProjectFiles(targetCwd)
|
|
2456
|
+
.filter((file) => file.endsWith('.css'))
|
|
2457
|
+
.filter((file) => {
|
|
2458
|
+
const relativeFile = relative(targetCwd, file)
|
|
2459
|
+
if (generatedFile && file === generatedFile) return false
|
|
2460
|
+
if (/synced-flow\.generated\.css$/.test(relativeFile)) return false
|
|
2461
|
+
const css = readFileSync(file, 'utf8')
|
|
2462
|
+
return findCustomTokenOverrideIndex(css) >= 0
|
|
2463
|
+
})
|
|
2464
|
+
.map((file) => relative(targetCwd, file))
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function findCustomTokenOverrideIndex(css) {
|
|
2468
|
+
const match = /(^|[;{\s])--sf-(space|colour|color|font|radius|container|gutter|grid|button|card|input)-[a-zA-Z0-9-]*\s*:/m.exec(css)
|
|
2469
|
+
return match ? (match.index ?? 0) + match[1].length : -1
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function listProjectFiles(dir) {
|
|
2473
|
+
if (!existsSync(dir)) return []
|
|
2474
|
+
return readdirSync(dir).flatMap((entry) => {
|
|
2475
|
+
if (isIgnoredDirectory(entry)) return []
|
|
2476
|
+
const fullPath = join(dir, entry)
|
|
2477
|
+
const stat = statSync(fullPath)
|
|
2478
|
+
if (stat.isDirectory()) return listProjectFiles(fullPath)
|
|
2479
|
+
if (/\.(css|tsx|ts|jsx|js|astro|html|php|phtml|twig|mdx|vue|svelte)$/.test(entry)) return [fullPath]
|
|
2480
|
+
return []
|
|
2481
|
+
})
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function isIgnoredDirectory(entry) {
|
|
2485
|
+
return new Set(['.git', '.next', 'coverage', 'dist', 'node_modules', 'out']).has(entry)
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
const semanticColours = {
|
|
2489
|
+
background: 'var(--color-background)',
|
|
2490
|
+
foreground: 'var(--color-foreground)',
|
|
2491
|
+
card: 'var(--color-card)',
|
|
2492
|
+
'card-foreground': 'var(--color-card-foreground)',
|
|
2493
|
+
popover: 'var(--color-popover)',
|
|
2494
|
+
'popover-foreground': 'var(--color-popover-foreground)',
|
|
2495
|
+
primary: 'var(--color-primary)',
|
|
2496
|
+
'primary-hover': 'var(--color-primary-hover)',
|
|
2497
|
+
'primary-label': 'var(--color-primary-label)',
|
|
2498
|
+
'primary-border': 'var(--color-primary-border)',
|
|
2499
|
+
'primary-foreground': 'var(--color-primary-foreground)',
|
|
2500
|
+
secondary: 'var(--color-secondary)',
|
|
2501
|
+
'secondary-foreground': 'var(--color-secondary-foreground)',
|
|
2502
|
+
muted: 'var(--color-muted)',
|
|
2503
|
+
'muted-foreground': 'var(--color-muted-foreground)',
|
|
2504
|
+
accent: 'var(--color-accent)',
|
|
2505
|
+
'accent-foreground': 'var(--color-accent-foreground)',
|
|
2506
|
+
destructive: 'var(--color-destructive)',
|
|
2507
|
+
'destructive-foreground': 'var(--color-destructive-foreground)',
|
|
2508
|
+
border: 'var(--color-border)',
|
|
2509
|
+
input: 'var(--color-input)',
|
|
2510
|
+
ring: 'var(--color-ring)',
|
|
2511
|
+
heading: 'var(--color-heading)',
|
|
2512
|
+
'page-text': 'var(--color-page-text)',
|
|
2513
|
+
body: 'var(--color-body)',
|
|
2514
|
+
'body-dark': 'var(--color-body-dark)',
|
|
2515
|
+
subdued: 'var(--color-subdued)',
|
|
2516
|
+
surface: 'var(--color-surface)',
|
|
2517
|
+
'surface-alt': 'var(--color-surface-alt)',
|
|
2518
|
+
'hero-bg': 'var(--color-hero-bg)',
|
|
2519
|
+
green: 'var(--color-green)',
|
|
2520
|
+
'green-dark': 'var(--color-green-dark)',
|
|
2521
|
+
'green-muted': 'var(--color-green-muted)',
|
|
2522
|
+
'green-light': 'var(--color-green-light)',
|
|
2523
|
+
'green-lighter': 'var(--color-green-lighter)',
|
|
2524
|
+
'green-bg': 'var(--color-green-bg)',
|
|
2525
|
+
'green-bg-alt': 'var(--color-green-bg-alt)',
|
|
2526
|
+
'green-vivid': 'var(--color-green-vivid)',
|
|
2527
|
+
'green-mid': 'var(--color-green-mid)',
|
|
2528
|
+
'green-deep': 'var(--color-green-deep)',
|
|
2529
|
+
'green-deepest': 'var(--color-green-deepest)',
|
|
2530
|
+
'green-wash': 'var(--color-green-wash)',
|
|
2531
|
+
'green-wash-alt': 'var(--color-green-wash-alt)',
|
|
2532
|
+
'green-gradient-from': 'var(--color-green-gradient-from)',
|
|
2533
|
+
'green-gradient-to': 'var(--color-green-gradient-to)',
|
|
2534
|
+
'deco-lime': 'var(--color-deco-lime)',
|
|
2535
|
+
'deco-blue': 'var(--color-deco-blue)',
|
|
2536
|
+
'deco-warm': 'var(--color-deco-warm)',
|
|
2537
|
+
'deco-warm-light': 'var(--color-deco-warm-light)',
|
|
2538
|
+
'deco-warm-mid': 'var(--color-deco-warm-mid)',
|
|
2539
|
+
'purple-light': 'var(--color-purple-light)',
|
|
2540
|
+
'purple-mid': 'var(--color-purple-mid)',
|
|
2541
|
+
'purple-dark': 'var(--color-purple-dark)',
|
|
2542
|
+
'purple-darker': 'var(--color-purple-darker)',
|
|
2543
|
+
'purple-icon': 'var(--color-purple-icon)',
|
|
2544
|
+
'warm-light': 'var(--color-warm-light)',
|
|
2545
|
+
'warm-mid': 'var(--color-warm-mid)',
|
|
2546
|
+
'warm-grad-to': 'var(--color-warm-grad-to)',
|
|
2547
|
+
'warm-grad-to-alt': 'var(--color-warm-grad-to-alt)',
|
|
2548
|
+
'warm-icon': 'var(--color-warm-icon)',
|
|
2549
|
+
'cool-light': 'var(--color-cool-light)',
|
|
2550
|
+
'cool-dark': 'var(--color-cool-dark)',
|
|
2551
|
+
'primary-glow': 'var(--color-primary-glow)',
|
|
2552
|
+
'primary-soft': 'var(--color-primary-soft)',
|
|
2553
|
+
'primary-soft-border': 'var(--color-primary-soft-border)',
|
|
2554
|
+
'primary-soft-border-light': 'var(--color-primary-soft-border-light)',
|
|
2555
|
+
'primary-soft-text': 'var(--color-primary-soft-text)',
|
|
2556
|
+
'primary-highlight': 'var(--color-primary-highlight)',
|
|
2557
|
+
white: 'var(--color-white)',
|
|
2558
|
+
black: 'var(--color-black)',
|
|
2559
|
+
transparent: 'transparent',
|
|
2560
|
+
current: 'currentColor',
|
|
2561
|
+
inherit: 'inherit',
|
|
2562
|
+
red: 'var(--color-red-600)',
|
|
2563
|
+
'red-50': 'oklch(97.1% 0.013 17.38)',
|
|
2564
|
+
'red-100': 'oklch(93.6% 0.032 17.717)',
|
|
2565
|
+
'red-200': 'oklch(88.5% 0.062 18.334)',
|
|
2566
|
+
'red-700': 'oklch(50.5% 0.213 27.518)',
|
|
2567
|
+
'green-50': 'oklch(98.2% 0.018 155.826)',
|
|
2568
|
+
'green-200': 'oklch(92.5% 0.084 155.995)',
|
|
2569
|
+
'green-800': 'oklch(44.8% 0.119 151.328)',
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
const palette = {
|
|
2573
|
+
slate: { 50: 'oklch(98.4% 0.003 247.858)', 100: 'oklch(96.8% 0.007 247.896)', 200: 'oklch(92.9% 0.013 255.508)', 300: 'oklch(86.9% 0.022 252.894)', 400: 'oklch(70.4% 0.04 256.788)', 500: 'oklch(55.4% 0.046 257.417)', 600: 'oklch(44.6% 0.043 257.281)', 700: 'oklch(37.2% 0.044 257.287)', 800: 'oklch(27.9% 0.041 260.031)', 900: 'oklch(20.8% 0.042 265.755)' },
|
|
2574
|
+
gray: { 50: 'oklch(98.5% 0.002 247.839)', 100: 'oklch(96.7% 0.003 264.542)', 200: 'oklch(92.8% 0.006 264.531)', 300: 'oklch(87.2% 0.01 258.338)', 400: 'oklch(70.7% 0.022 261.325)', 500: 'oklch(55.1% 0.027 264.364)', 600: 'oklch(44.6% 0.03 256.802)', 700: 'oklch(37.3% 0.034 259.733)', 800: 'oklch(27.8% 0.033 256.848)', 900: 'oklch(21% 0.034 264.665)' },
|
|
2575
|
+
blue: { 50: 'oklch(97% 0.014 254.604)', 100: 'oklch(93.2% 0.032 255.585)', 200: 'oklch(88.2% 0.059 254.128)', 300: 'oklch(80.9% 0.105 251.813)', 400: 'oklch(70.7% 0.165 254.624)', 500: 'oklch(62.3% 0.214 259.815)', 600: 'oklch(54.6% 0.245 262.881)', 700: 'oklch(48.8% 0.243 264.376)', 800: 'oklch(42.4% 0.199 265.638)', 900: 'oklch(37.9% 0.146 265.522)', 950: 'oklch(28.2% 0.091 267.935)' },
|
|
2576
|
+
cyan: { 50: 'oklch(98.4% 0.019 200.873)', 100: 'oklch(95.6% 0.045 203.388)', 200: 'oklch(91.7% 0.08 205.041)', 300: 'oklch(86.5% 0.127 207.078)', 400: 'oklch(78.9% 0.154 211.53)', 500: 'oklch(71.5% 0.143 215.221)', 600: 'oklch(60.9% 0.126 221.723)', 700: 'oklch(52% 0.105 223.128)', 800: 'oklch(45% 0.085 224.283)', 900: 'oklch(39.8% 0.07 227.392)', 950: 'oklch(30.2% 0.056 229.695)' },
|
|
2577
|
+
green: { 50: 'oklch(98.2% 0.018 155.826)', 100: 'oklch(96.2% 0.044 156.743)', 200: 'oklch(92.5% 0.084 155.995)', 300: 'oklch(87.1% 0.15 154.449)', 400: 'oklch(79.2% 0.209 151.711)', 500: 'oklch(72.3% 0.219 149.579)', 600: 'oklch(62.7% 0.194 149.214)', 700: 'oklch(52.7% 0.154 150.069)', 800: 'oklch(44.8% 0.119 151.328)', 900: 'oklch(39.3% 0.095 152.535)', 950: 'oklch(26.6% 0.065 152.934)' },
|
|
2578
|
+
orange: { 50: 'oklch(98% 0.016 73.684)', 100: 'oklch(95.4% 0.038 75.164)', 200: 'oklch(90.1% 0.076 70.697)', 300: 'oklch(83.7% 0.128 66.29)', 400: 'oklch(75% 0.183 55.934)', 500: 'oklch(70.5% 0.213 47.604)', 600: 'oklch(64.6% 0.222 41.116)', 700: 'oklch(55.3% 0.195 38.402)', 800: 'oklch(47% 0.157 37.304)', 900: 'oklch(40.8% 0.123 38.172)', 950: 'oklch(26.6% 0.079 36.259)' },
|
|
2579
|
+
red: { 50: 'oklch(97.1% 0.013 17.38)', 100: 'oklch(93.6% 0.032 17.717)', 200: 'oklch(88.5% 0.062 18.334)', 300: 'oklch(80.8% 0.114 19.571)', 400: 'oklch(70.4% 0.191 22.216)', 500: 'oklch(63.7% 0.237 25.331)', 600: 'oklch(57.7% 0.245 27.325)', 700: 'oklch(50.5% 0.213 27.518)', 800: 'oklch(44.4% 0.177 26.899)', 900: 'oklch(39.6% 0.141 25.723)', 950: 'oklch(25.8% 0.092 26.042)' },
|
|
2580
|
+
pink: { 600: 'oklch(59.2% 0.249 0.584)', 700: 'oklch(52.5% 0.223 3.958)' },
|
|
2581
|
+
amber: { 600: 'oklch(66.6% 0.179 58.318)' },
|
|
2582
|
+
yellow: { 400: 'oklch(85.2% 0.199 91.936)', 500: 'oklch(79.5% 0.184 86.047)', 700: 'oklch(55.4% 0.135 66.442)' },
|
|
2583
|
+
teal: { 500: 'oklch(70.4% 0.14 182.503)', 600: 'oklch(60% 0.118 184.704)' },
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
const fontSizeMap = {
|
|
2587
|
+
xs: 'var(--step--2)',
|
|
2588
|
+
sm: 'var(--step--1)',
|
|
2589
|
+
base: 'var(--step-0)',
|
|
2590
|
+
lg: 'var(--step-1)',
|
|
2591
|
+
xl: 'var(--step-2)',
|
|
2592
|
+
'2xl': 'var(--step-3)',
|
|
2593
|
+
'3xl': 'var(--step-4)',
|
|
2594
|
+
'4xl': 'var(--step-5)',
|
|
2595
|
+
'5xl': 'var(--step-6)',
|
|
2596
|
+
'6xl': 'var(--step-7)',
|
|
2597
|
+
'7xl': 'var(--step-8)',
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
const lineHeightMap = {
|
|
2601
|
+
none: '1',
|
|
2602
|
+
tight: '1.1',
|
|
2603
|
+
snug: '1.25',
|
|
2604
|
+
normal: '1.5',
|
|
2605
|
+
relaxed: '1.625',
|
|
2606
|
+
loose: '2',
|
|
2607
|
+
3: '.75rem',
|
|
2608
|
+
4: '1rem',
|
|
2609
|
+
5: '1.25rem',
|
|
2610
|
+
6: '1.5rem',
|
|
2611
|
+
7: '1.75rem',
|
|
2612
|
+
8: '2rem',
|
|
2613
|
+
9: '2.25rem',
|
|
2614
|
+
10: '2.5rem',
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
const fontWeightMap = {
|
|
2618
|
+
thin: 100,
|
|
2619
|
+
extralight: 200,
|
|
2620
|
+
light: 300,
|
|
2621
|
+
normal: 400,
|
|
2622
|
+
medium: 500,
|
|
2623
|
+
semibold: 600,
|
|
2624
|
+
bold: 700,
|
|
2625
|
+
extrabold: 800,
|
|
2626
|
+
black: 900,
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
function fluidClamp(minPx, maxPx) {
|
|
2630
|
+
if (minPx === maxPx) return `${round(minPx / 16)}rem`
|
|
2631
|
+
const slope = (maxPx - minPx) / (fluidConfig.maxViewport - fluidConfig.minViewport)
|
|
2632
|
+
const intercept = minPx - slope * fluidConfig.minViewport
|
|
2633
|
+
return `clamp(${round(minPx / 16)}rem, ${round(intercept / 16)}rem + ${round(slope * 100)}vw, ${round(maxPx / 16)}rem)`
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
function typeStep(step) {
|
|
2637
|
+
const minPx = fluidConfig.minRoot * Math.pow(fluidConfig.minTypeScale, step)
|
|
2638
|
+
const maxPx = fluidConfig.maxRoot * Math.pow(fluidConfig.maxTypeScale, step)
|
|
2639
|
+
return fluidClamp(minPx, maxPx)
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
function spacingUnit(value) {
|
|
2643
|
+
if (value === '0') return '0'
|
|
2644
|
+
if (value === 'px') return '1px'
|
|
2645
|
+
const number = Number(value)
|
|
2646
|
+
if (Number.isNaN(number)) return null
|
|
2647
|
+
return fluidClamp(number * 4, number * 4.5)
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function round(value) {
|
|
2651
|
+
return Number(value.toFixed(4))
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
function pxToRem(value) {
|
|
2655
|
+
return value.replace(/(-?\d*\.?\d+)px/g, (_, number) => {
|
|
2656
|
+
const rem = Number(number) / 16
|
|
2657
|
+
return `${round(rem)}rem`
|
|
2658
|
+
})
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
function arbitraryValue(value) {
|
|
2662
|
+
return pxToRem(value.replace(/_/g, ' '))
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
function listFiles(dir) {
|
|
2666
|
+
const fullDir = join(repoRoot, dir)
|
|
2667
|
+
if (!existsSync(fullDir)) return []
|
|
2668
|
+
|
|
2669
|
+
return readdirSync(fullDir).flatMap((entry) => {
|
|
2670
|
+
if (isIgnoredDirectory(entry)) return []
|
|
2671
|
+
const fullPath = join(fullDir, entry)
|
|
2672
|
+
const stat = statSync(fullPath)
|
|
2673
|
+
if (stat.isDirectory()) return listFiles(relative(repoRoot, fullPath))
|
|
2674
|
+
if (/\.(tsx|ts|jsx|js|astro|html|php|phtml|twig|mdx|vue|svelte)$/.test(entry)) return [fullPath]
|
|
2675
|
+
return []
|
|
2676
|
+
})
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function collectClassTokens() {
|
|
2680
|
+
const tokens = new Set([
|
|
2681
|
+
'sf-container',
|
|
2682
|
+
'sf-section',
|
|
2683
|
+
'sf-stack',
|
|
2684
|
+
'sf-cluster',
|
|
2685
|
+
'sf-repel',
|
|
2686
|
+
'sf-grid',
|
|
2687
|
+
'sf-auto-grid',
|
|
2688
|
+
'sf-sidebar',
|
|
2689
|
+
'sf-frame',
|
|
2690
|
+
'sf-flow',
|
|
2691
|
+
'sf-card',
|
|
2692
|
+
'sf-button',
|
|
2693
|
+
'sf-button--default',
|
|
2694
|
+
'sf-button--outline',
|
|
2695
|
+
'sf-button--secondary',
|
|
2696
|
+
'sf-button--ghost',
|
|
2697
|
+
'sf-button--link',
|
|
2698
|
+
'sf-button--destructive',
|
|
2699
|
+
'sf-button--sm',
|
|
2700
|
+
'sf-button--lg',
|
|
2701
|
+
'sf-button--icon',
|
|
2702
|
+
...safelist,
|
|
2703
|
+
])
|
|
2704
|
+
|
|
2705
|
+
const stringPattern = /(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g
|
|
2706
|
+
const files = sourceDirs.flatMap(listFiles)
|
|
2707
|
+
|
|
2708
|
+
for (const file of files) {
|
|
2709
|
+
const source = readFileSync(file, 'utf8')
|
|
2710
|
+
let match
|
|
2711
|
+
while ((match = stringPattern.exec(source))) {
|
|
2712
|
+
const raw = match[2].replace(/\$\{[\s\S]*?\}/g, ' ')
|
|
2713
|
+
for (const token of raw.split(/\s+/)) {
|
|
2714
|
+
const cleaned = token.trim()
|
|
2715
|
+
if (looksLikeClass(cleaned)) tokens.add(cleaned)
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
return [...tokens].sort()
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
function looksLikeClass(token) {
|
|
2724
|
+
if (!token || token.length > 240) return false
|
|
2725
|
+
if (/[\n\r{};]/.test(token)) return false
|
|
2726
|
+
if (/[,.]$/.test(token)) return false
|
|
2727
|
+
if (/^(http|https|mailto|tel):/.test(token)) return false
|
|
2728
|
+
if (/^\/|^\.\//.test(token)) return false
|
|
2729
|
+
if (/["'<>()]/.test(token) && !/^[a-z-]+\[/.test(token) && !/^[a-z-]+-\[/.test(token) && !/^\[&/.test(token)) return false
|
|
2730
|
+
if (/^[\[\](),:]+$/.test(token) || token.endsWith(':')) return false
|
|
2731
|
+
if (/^(sf|text|bg|border|p[trblxy]?|m[trblxy]?|gap)-$/.test(token)) return false
|
|
2732
|
+
if (/^[A-Z][A-Za-z]+$/.test(token)) return false
|
|
2733
|
+
if (token.startsWith('--')) return false
|
|
2734
|
+
|
|
2735
|
+
const { base } = splitVariants(token)
|
|
2736
|
+
const falseTokens = new Set([
|
|
2737
|
+
'content-heavy',
|
|
2738
|
+
'list-synced-free-tools',
|
|
2739
|
+
'list-synced-insights',
|
|
2740
|
+
'list-synced-services',
|
|
2741
|
+
'object-alt',
|
|
2742
|
+
'right',
|
|
2743
|
+
'rights',
|
|
2744
|
+
'rings',
|
|
2745
|
+
'scale-item',
|
|
2746
|
+
'select-name',
|
|
2747
|
+
'shadows',
|
|
2748
|
+
'text-section',
|
|
2749
|
+
'top',
|
|
2750
|
+
'transformation',
|
|
2751
|
+
'transforming',
|
|
2752
|
+
'transitions',
|
|
2753
|
+
])
|
|
2754
|
+
if (falseTokens.has(base)) return false
|
|
2755
|
+
const exact = new Set([
|
|
2756
|
+
'absolute',
|
|
2757
|
+
'relative',
|
|
2758
|
+
'fixed',
|
|
2759
|
+
'sticky',
|
|
2760
|
+
'static',
|
|
2761
|
+
'isolate',
|
|
2762
|
+
'block',
|
|
2763
|
+
'inline',
|
|
2764
|
+
'inline-block',
|
|
2765
|
+
'inline-flex',
|
|
2766
|
+
'flex',
|
|
2767
|
+
'grid',
|
|
2768
|
+
'hidden',
|
|
2769
|
+
'contents',
|
|
2770
|
+
'sr-only',
|
|
2771
|
+
'not-sr-only',
|
|
2772
|
+
'group',
|
|
2773
|
+
'peer',
|
|
2774
|
+
'grow',
|
|
2775
|
+
'shrink',
|
|
2776
|
+
'uppercase',
|
|
2777
|
+
'lowercase',
|
|
2778
|
+
'capitalize',
|
|
2779
|
+
'truncate',
|
|
2780
|
+
'underline',
|
|
2781
|
+
'italic',
|
|
2782
|
+
'antialiased',
|
|
2783
|
+
])
|
|
2784
|
+
|
|
2785
|
+
return Boolean(
|
|
2786
|
+
token.startsWith('sf-') ||
|
|
2787
|
+
exact.has(base) ||
|
|
2788
|
+
/^\[.+:.+\]$/.test(base) ||
|
|
2789
|
+
/^(container|pointer-events-|cursor-|select-|resize|appearance-|overflow-|object-|inset-|top-|right-|bottom-|left-|z-|order-|col-|row-|m[trblxy]?-\S+|p[trblxy]?-\S+|gap-\S+|space-[xy]-\S+|w-\S+|h-\S+|size-\S+|min-[wh]-\S+|max-[wh]-\S+|basis-\S+|flex-\S+|items-|justify-|content-|self-|place-|grid-|rounded|border|divide-|bg-|from-|via-|to-|text-|font-|leading-|tracking-|normal-case|whitespace-|break-|line-clamp-|shadow|ring|outline|opacity-|blur|backdrop-|transition|duration-|ease-|delay-|animate-|transform|origin-|translate-|scale-|rotate-|skew-|fill-|stroke-|accent-|prose|list-|decoration-|underline-offset-|no-underline)/.test(base)
|
|
2790
|
+
)
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
function splitVariants(token) {
|
|
2794
|
+
const parts = []
|
|
2795
|
+
let current = ''
|
|
2796
|
+
let depth = 0
|
|
2797
|
+
|
|
2798
|
+
for (const char of token) {
|
|
2799
|
+
if (char === '[') depth += 1
|
|
2800
|
+
if (char === ']') depth -= 1
|
|
2801
|
+
if (char === ':' && depth === 0) {
|
|
2802
|
+
parts.push(current)
|
|
2803
|
+
current = ''
|
|
2804
|
+
} else {
|
|
2805
|
+
current += char
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
parts.push(current)
|
|
2810
|
+
return { variants: parts.slice(0, -1), base: parts.at(-1) ?? token }
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
function attrEscape(value) {
|
|
2814
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
function selectorFor(token, variants) {
|
|
2818
|
+
let selector = `[class~="${attrEscape(token)}"]`
|
|
2819
|
+
let pseudoElement = ''
|
|
2820
|
+
const wrappers = []
|
|
2821
|
+
|
|
2822
|
+
for (const variant of variants) {
|
|
2823
|
+
if (breakpoints[variant]) {
|
|
2824
|
+
if (!responsiveVariants) return null
|
|
2825
|
+
wrappers.push({ type: 'media', condition: `(min-width: ${breakpoints[variant]})` })
|
|
2826
|
+
continue
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
if (variant === 'motion-reduce') {
|
|
2830
|
+
wrappers.push({ type: 'media', condition: '(prefers-reduced-motion: reduce)' })
|
|
2831
|
+
continue
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
if (variant === 'motion-safe') {
|
|
2835
|
+
wrappers.push({ type: 'media', condition: '(prefers-reduced-motion: no-preference)' })
|
|
2836
|
+
continue
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
if (variant === 'dark') {
|
|
2840
|
+
selector = `.dark ${selector}`
|
|
2841
|
+
continue
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
if (variant === 'hover' || variant === 'focus' || variant === 'focus-visible' || variant === 'focus-within' || variant === 'active') {
|
|
2845
|
+
selector += `:${variant}`
|
|
2846
|
+
continue
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
if (variant === 'disabled') {
|
|
2850
|
+
selector += ':disabled'
|
|
2851
|
+
continue
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
if (variant === 'first' || variant === 'last' || variant === 'odd' || variant === 'even') {
|
|
2855
|
+
selector += `:${variant === 'first' ? 'first-child' : variant === 'last' ? 'last-child' : variant === 'odd' ? 'nth-child(odd)' : 'nth-child(even)'}`
|
|
2856
|
+
continue
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
if (variant === 'before' || variant === 'after') {
|
|
2860
|
+
pseudoElement = `::${variant}`
|
|
2861
|
+
continue
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
if (variant === 'placeholder') {
|
|
2865
|
+
pseudoElement = '::placeholder'
|
|
2866
|
+
continue
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
if (variant === 'file') {
|
|
2870
|
+
pseudoElement = '::file-selector-button'
|
|
2871
|
+
continue
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
if (variant === 'group-hover') {
|
|
2875
|
+
selector = `.group:hover ${selector}`
|
|
2876
|
+
continue
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
if (variant === 'group-focus') {
|
|
2880
|
+
selector = `.group:focus ${selector}`
|
|
2881
|
+
continue
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
if (variant.startsWith('data-[')) {
|
|
2885
|
+
const [name, value] = variant.slice(6, -1).split('=')
|
|
2886
|
+
selector += value ? `[data-${name}="${value}"]` : `[data-${name}]`
|
|
2887
|
+
continue
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
if (variant.startsWith('aria-[')) {
|
|
2891
|
+
const [name, value] = variant.slice(6, -1).split('=')
|
|
2892
|
+
selector += value ? `[aria-${name}="${value}"]` : `[aria-${name}]`
|
|
2893
|
+
continue
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
if (variant.startsWith('[&')) {
|
|
2897
|
+
selector = variant.slice(1, -1).replace('&', selector).replace(/_/g, ' ')
|
|
2898
|
+
continue
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
return {
|
|
2903
|
+
selector: selector + pseudoElement,
|
|
2904
|
+
wrappers,
|
|
2905
|
+
needsPseudoContent: pseudoElement === '::before' || pseudoElement === '::after',
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
function colourValue(name) {
|
|
2910
|
+
const [raw, opacity] = name.split('/')
|
|
2911
|
+
const paletteMatch = raw.match(/^([a-z]+)-(\d+)$/)
|
|
2912
|
+
const base = semanticColours[raw] ?? (paletteMatch ? palette[paletteMatch[1]]?.[paletteMatch[2]] : null)
|
|
2913
|
+
if (!base) return null
|
|
2914
|
+
if (!opacity) return base
|
|
2915
|
+
const amount = Number(opacity)
|
|
2916
|
+
if (Number.isNaN(amount)) return base
|
|
2917
|
+
if (base === 'transparent' || base === 'currentColor' || base === 'inherit') return base
|
|
2918
|
+
return `color-mix(in oklch, ${base} ${amount}%, transparent)`
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
function declarationsFor(base) {
|
|
2922
|
+
if (base.startsWith('sf-')) return []
|
|
2923
|
+
|
|
2924
|
+
if (base.startsWith('[') && base.endsWith(']') && base.includes(':')) {
|
|
2925
|
+
const body = base.slice(1, -1)
|
|
2926
|
+
const [property, ...valueParts] = body.split(':')
|
|
2927
|
+
return [[property, arbitraryValue(valueParts.join(':'))]]
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
const important = base.endsWith('!')
|
|
2931
|
+
if (important) base = base.slice(0, -1)
|
|
2932
|
+
|
|
2933
|
+
const declarations = rawDeclarationsFor(base)
|
|
2934
|
+
if (!important || !declarations) return declarations
|
|
2935
|
+
return declarations.map(([property, value]) => [property, `${value} !important`])
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
function rawDeclarationsFor(base) {
|
|
2939
|
+
const arbitrary = base.match(/^([a-z-]+)-\[(.+)\]$/)
|
|
2940
|
+
if (arbitrary) {
|
|
2941
|
+
const [, prefix, rawValue] = arbitrary
|
|
2942
|
+
const value = arbitraryValue(rawValue)
|
|
2943
|
+
const spacingProps = {
|
|
2944
|
+
p: ['padding'],
|
|
2945
|
+
px: ['padding-left', 'padding-right'],
|
|
2946
|
+
py: ['padding-top', 'padding-bottom'],
|
|
2947
|
+
pt: ['padding-top'],
|
|
2948
|
+
pr: ['padding-right'],
|
|
2949
|
+
pb: ['padding-bottom'],
|
|
2950
|
+
pl: ['padding-left'],
|
|
2951
|
+
m: ['margin'],
|
|
2952
|
+
mx: ['margin-left', 'margin-right'],
|
|
2953
|
+
my: ['margin-top', 'margin-bottom'],
|
|
2954
|
+
mt: ['margin-top'],
|
|
2955
|
+
mr: ['margin-right'],
|
|
2956
|
+
mb: ['margin-bottom'],
|
|
2957
|
+
ml: ['margin-left'],
|
|
2958
|
+
gap: ['gap'],
|
|
2959
|
+
}
|
|
2960
|
+
if (spacingProps[prefix]) return spacingProps[prefix].map((property) => [property, value])
|
|
2961
|
+
if (prefix === 'bg') return [['background', value]]
|
|
2962
|
+
if (prefix === 'border') return [['border-color', value]]
|
|
2963
|
+
if (prefix === 'ring') return [['--sf-ring-color', value]]
|
|
2964
|
+
if (prefix === 'from') return [['--sf-gradient-from', value], ['--sf-gradient-stops', 'var(--sf-gradient-from), var(--sf-gradient-to, transparent)']]
|
|
2965
|
+
if (prefix === 'to') return [['--sf-gradient-to', value]]
|
|
2966
|
+
if (prefix === 'text') {
|
|
2967
|
+
if (/^(oklch|rgb|hsl|#|color-mix)/.test(value)) return [['color', value]]
|
|
2968
|
+
return [['font-size', value]]
|
|
2969
|
+
}
|
|
2970
|
+
if (prefix === 'leading') return [['line-height', value]]
|
|
2971
|
+
if (prefix === 'tracking') return [['letter-spacing', value]]
|
|
2972
|
+
if (prefix === 'grid-cols') return [['grid-template-columns', value]]
|
|
2973
|
+
if (prefix === 'grid-rows') return [['grid-template-rows', value]]
|
|
2974
|
+
if (prefix === 'max-w') return [['max-width', value]]
|
|
2975
|
+
if (prefix === 'min-w') return [['min-width', value]]
|
|
2976
|
+
if (prefix === 'max-h') return [['max-height', value]]
|
|
2977
|
+
if (prefix === 'min-h') return [['min-height', value]]
|
|
2978
|
+
if (prefix === 'w') return [['width', value]]
|
|
2979
|
+
if (prefix === 'h') return [['height', value]]
|
|
2980
|
+
if (prefix === 'basis') return [['flex-basis', value]]
|
|
2981
|
+
if (prefix === 'top' || prefix === 'right' || prefix === 'bottom' || prefix === 'left' || prefix === 'inset') return [[prefix, value]]
|
|
2982
|
+
if (prefix === 'shadow') return [['box-shadow', value]]
|
|
2983
|
+
if (prefix === 'opacity') return [['opacity', value]]
|
|
2984
|
+
if (prefix === 'z') return [['z-index', value]]
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
const displayMap = {
|
|
2988
|
+
block: 'block',
|
|
2989
|
+
'inline-block': 'inline-block',
|
|
2990
|
+
inline: 'inline',
|
|
2991
|
+
'inline-flex': 'inline-flex',
|
|
2992
|
+
flex: 'flex',
|
|
2993
|
+
grid: 'grid',
|
|
2994
|
+
hidden: 'none',
|
|
2995
|
+
contents: 'contents',
|
|
2996
|
+
}
|
|
2997
|
+
if (displayMap[base]) return [['display', displayMap[base]]]
|
|
2998
|
+
|
|
2999
|
+
if (base === 'sr-only') {
|
|
3000
|
+
return [
|
|
3001
|
+
['position', 'absolute'],
|
|
3002
|
+
['width', '1px'],
|
|
3003
|
+
['height', '1px'],
|
|
3004
|
+
['padding', '0'],
|
|
3005
|
+
['margin', '-1px'],
|
|
3006
|
+
['overflow', 'hidden'],
|
|
3007
|
+
['clip', 'rect(0, 0, 0, 0)'],
|
|
3008
|
+
['white-space', 'nowrap'],
|
|
3009
|
+
['border-width', '0'],
|
|
3010
|
+
]
|
|
3011
|
+
}
|
|
3012
|
+
if (base === 'not-sr-only') {
|
|
3013
|
+
return [
|
|
3014
|
+
['position', 'static'],
|
|
3015
|
+
['width', 'auto'],
|
|
3016
|
+
['height', 'auto'],
|
|
3017
|
+
['padding', '0'],
|
|
3018
|
+
['margin', '0'],
|
|
3019
|
+
['overflow', 'visible'],
|
|
3020
|
+
['clip', 'auto'],
|
|
3021
|
+
['white-space', 'normal'],
|
|
3022
|
+
]
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
const positionMap = {
|
|
3026
|
+
static: 'static',
|
|
3027
|
+
fixed: 'fixed',
|
|
3028
|
+
absolute: 'absolute',
|
|
3029
|
+
relative: 'relative',
|
|
3030
|
+
sticky: 'sticky',
|
|
3031
|
+
}
|
|
3032
|
+
if (positionMap[base]) return [['position', positionMap[base]]]
|
|
3033
|
+
if (base === 'isolate') return [['isolation', 'isolate']]
|
|
3034
|
+
if (base === 'antialiased') return [['-webkit-font-smoothing', 'antialiased'], ['-moz-osx-font-smoothing', 'grayscale']]
|
|
3035
|
+
if (base === 'container' || base === 'sf-container') return null
|
|
3036
|
+
if (base === 'group' || base === 'peer') return []
|
|
3037
|
+
|
|
3038
|
+
const simpleMaps = [
|
|
3039
|
+
[/^overflow-(hidden|visible|auto|scroll|clip)$/, 'overflow'],
|
|
3040
|
+
[/^overflow-x-(hidden|visible|auto|scroll|clip)$/, 'overflow-x'],
|
|
3041
|
+
[/^overflow-y-(hidden|visible|auto|scroll|clip)$/, 'overflow-y'],
|
|
3042
|
+
[/^object-(cover|contain|fill|none|scale-down)$/, 'object-fit'],
|
|
3043
|
+
[/^whitespace-(normal|nowrap|pre|pre-line|pre-wrap|break-spaces)$/, 'white-space'],
|
|
3044
|
+
[/^break-(normal|words|all|keep)$/, 'overflow-wrap'],
|
|
3045
|
+
[/^cursor-(pointer|default|not-allowed|grab|grabbing|text)$/, 'cursor'],
|
|
3046
|
+
[/^resize-(none|both|x|y)$/, 'resize'],
|
|
3047
|
+
[/^select-(none|text|all|auto)$/, 'user-select'],
|
|
3048
|
+
[/^pointer-events-(none|auto)$/, 'pointer-events'],
|
|
3049
|
+
]
|
|
3050
|
+
for (const [pattern, property] of simpleMaps) {
|
|
3051
|
+
const match = base.match(pattern)
|
|
3052
|
+
if (match) return [[property, match[1] === 'words' ? 'break-word' : match[1]]]
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
if (base === 'appearance-none') return [['appearance', 'none']]
|
|
3056
|
+
if (base === 'resize') return [['resize', 'both']]
|
|
3057
|
+
if (base === 'truncate') return [['overflow', 'hidden'], ['text-overflow', 'ellipsis'], ['white-space', 'nowrap']]
|
|
3058
|
+
const lineClamp = base.match(/^line-clamp-(\d+)$/)
|
|
3059
|
+
if (lineClamp) return [['overflow', 'hidden'], ['display', '-webkit-box'], ['-webkit-box-orient', 'vertical'], ['-webkit-line-clamp', lineClamp[1]]]
|
|
3060
|
+
const zIndex = base.match(/^z-(auto|\d+)$/)
|
|
3061
|
+
if (zIndex) return [['z-index', zIndex[1]]]
|
|
3062
|
+
if (base === 'uppercase' || base === 'lowercase' || base === 'capitalize') return [['text-transform', base]]
|
|
3063
|
+
if (base === 'normal-case') return [['text-transform', 'none']]
|
|
3064
|
+
if (base === 'underline') return [['text-decoration-line', 'underline']]
|
|
3065
|
+
if (base === 'no-underline') return [['text-decoration-line', 'none']]
|
|
3066
|
+
if (base === 'italic') return [['font-style', 'italic']]
|
|
3067
|
+
if (base === 'not-italic') return [['font-style', 'normal']]
|
|
3068
|
+
|
|
3069
|
+
const inset = sideUtility(base, ['inset', 'top', 'right', 'bottom', 'left'])
|
|
3070
|
+
if (inset) return inset
|
|
3071
|
+
|
|
3072
|
+
const spacing = spacingUtility(base)
|
|
3073
|
+
if (spacing) return spacing
|
|
3074
|
+
|
|
3075
|
+
const sizing = sizingUtility(base)
|
|
3076
|
+
if (sizing) return sizing
|
|
3077
|
+
|
|
3078
|
+
const flexGrid = flexGridUtility(base)
|
|
3079
|
+
if (flexGrid) return flexGrid
|
|
3080
|
+
|
|
3081
|
+
const typography = typographyUtility(base)
|
|
3082
|
+
if (typography) return typography
|
|
3083
|
+
|
|
3084
|
+
const colour = colourUtility(base)
|
|
3085
|
+
if (colour) return colour
|
|
3086
|
+
|
|
3087
|
+
const border = borderUtility(base)
|
|
3088
|
+
if (border) return border
|
|
3089
|
+
|
|
3090
|
+
const effects = effectUtility(base)
|
|
3091
|
+
if (effects) return effects
|
|
3092
|
+
|
|
3093
|
+
const transform = transformUtility(base)
|
|
3094
|
+
if (transform) return transform
|
|
3095
|
+
|
|
3096
|
+
return null
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
function sideUtility(base, prefixes) {
|
|
3100
|
+
for (const prefix of prefixes) {
|
|
3101
|
+
const match = base.match(new RegExp(`^-?${prefix}-(.+)$`))
|
|
3102
|
+
if (!match) continue
|
|
3103
|
+
const negative = base.startsWith('-')
|
|
3104
|
+
const value = spacingUnit(match[1]) ?? sizeKeyword(match[1])
|
|
3105
|
+
if (!value) continue
|
|
3106
|
+
const resolved = negative ? `calc(${value} * -1)` : value
|
|
3107
|
+
if (prefix === 'inset') return [['inset', resolved]]
|
|
3108
|
+
return [[prefix, resolved]]
|
|
3109
|
+
}
|
|
3110
|
+
if (base === 'inset-0') return [['inset', '0']]
|
|
3111
|
+
if (base === 'inset-x-0') return [['left', '0'], ['right', '0']]
|
|
3112
|
+
if (base === 'inset-y-0') return [['top', '0'], ['bottom', '0']]
|
|
3113
|
+
return null
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
function spacingUtility(base) {
|
|
3117
|
+
const negative = base.startsWith('-')
|
|
3118
|
+
const token = negative ? base.slice(1) : base
|
|
3119
|
+
const match = token.match(/^(gap-x|gap-y|space-x|space-y|m|mx|my|mt|mr|mb|ml|p|px|py|pt|pr|pb|pl|gap)-(.+)$/)
|
|
3120
|
+
if (!match) return null
|
|
3121
|
+
|
|
3122
|
+
const [, prefix, valueName] = match
|
|
3123
|
+
const value = spacingUnit(valueName) ?? sizeKeyword(valueName)
|
|
3124
|
+
if (!value) return null
|
|
3125
|
+
const resolved = negative ? `calc(${value} * -1)` : value
|
|
3126
|
+
const map = {
|
|
3127
|
+
m: ['margin'],
|
|
3128
|
+
mx: ['margin-left', 'margin-right'],
|
|
3129
|
+
my: ['margin-top', 'margin-bottom'],
|
|
3130
|
+
mt: ['margin-top'],
|
|
3131
|
+
mr: ['margin-right'],
|
|
3132
|
+
mb: ['margin-bottom'],
|
|
3133
|
+
ml: ['margin-left'],
|
|
3134
|
+
p: ['padding'],
|
|
3135
|
+
px: ['padding-left', 'padding-right'],
|
|
3136
|
+
py: ['padding-top', 'padding-bottom'],
|
|
3137
|
+
pt: ['padding-top'],
|
|
3138
|
+
pr: ['padding-right'],
|
|
3139
|
+
pb: ['padding-bottom'],
|
|
3140
|
+
pl: ['padding-left'],
|
|
3141
|
+
gap: ['gap'],
|
|
3142
|
+
'gap-x': ['column-gap'],
|
|
3143
|
+
'gap-y': ['row-gap'],
|
|
3144
|
+
}
|
|
3145
|
+
if (prefix === 'space-x') return [['--sf-space-x', resolved]]
|
|
3146
|
+
if (prefix === 'space-y') return [['--sf-space-y', resolved]]
|
|
3147
|
+
return map[prefix].map((property) => [property, resolved])
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
function sizeKeyword(valueName) {
|
|
3151
|
+
const map = {
|
|
3152
|
+
auto: 'auto',
|
|
3153
|
+
full: '100%',
|
|
3154
|
+
screen: '100svw',
|
|
3155
|
+
svw: '100svw',
|
|
3156
|
+
svh: '100svh',
|
|
3157
|
+
min: 'min-content',
|
|
3158
|
+
max: 'max-content',
|
|
3159
|
+
fit: 'fit-content',
|
|
3160
|
+
prose: '65ch',
|
|
3161
|
+
sm: '24rem',
|
|
3162
|
+
md: '28rem',
|
|
3163
|
+
lg: '32rem',
|
|
3164
|
+
xl: '36rem',
|
|
3165
|
+
'2xl': '42rem',
|
|
3166
|
+
'3xl': '48rem',
|
|
3167
|
+
'4xl': '56rem',
|
|
3168
|
+
'5xl': '64rem',
|
|
3169
|
+
'6xl': '72rem',
|
|
3170
|
+
'7xl': '80rem',
|
|
3171
|
+
'1/2': '50%',
|
|
3172
|
+
'1/3': '33.333333%',
|
|
3173
|
+
'2/3': '66.666667%',
|
|
3174
|
+
'1/4': '25%',
|
|
3175
|
+
'2/4': '50%',
|
|
3176
|
+
'3/4': '75%',
|
|
3177
|
+
'1/5': '20%',
|
|
3178
|
+
'2/5': '40%',
|
|
3179
|
+
'3/5': '60%',
|
|
3180
|
+
'4/5': '80%',
|
|
3181
|
+
'1/6': '16.666667%',
|
|
3182
|
+
'5/6': '83.333333%',
|
|
3183
|
+
}
|
|
3184
|
+
return map[valueName] ?? null
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
function sizingUtility(base) {
|
|
3188
|
+
const match = base.match(/^(w|h|min-w|min-h|max-w|max-h|size|basis)-(.+)$/)
|
|
3189
|
+
if (!match) return null
|
|
3190
|
+
const [, prefix, valueName] = match
|
|
3191
|
+
const value = spacingUnit(valueName) ?? sizeKeyword(valueName)
|
|
3192
|
+
if (!value) return null
|
|
3193
|
+
const props = {
|
|
3194
|
+
w: ['width'],
|
|
3195
|
+
h: ['height'],
|
|
3196
|
+
'min-w': ['min-width'],
|
|
3197
|
+
'min-h': ['min-height'],
|
|
3198
|
+
'max-w': ['max-width'],
|
|
3199
|
+
'max-h': ['max-height'],
|
|
3200
|
+
size: ['width', 'height'],
|
|
3201
|
+
basis: ['flex-basis'],
|
|
3202
|
+
}
|
|
3203
|
+
return props[prefix].map((property) => [property, value])
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
function flexGridUtility(base) {
|
|
3207
|
+
const direct = {
|
|
3208
|
+
'flex-row': [['flex-direction', 'row']],
|
|
3209
|
+
'flex-row-reverse': [['flex-direction', 'row-reverse']],
|
|
3210
|
+
'flex-col': [['flex-direction', 'column']],
|
|
3211
|
+
'flex-col-reverse': [['flex-direction', 'column-reverse']],
|
|
3212
|
+
'flex-wrap': [['flex-wrap', 'wrap']],
|
|
3213
|
+
'flex-nowrap': [['flex-wrap', 'nowrap']],
|
|
3214
|
+
'flex-1': [['flex', '1 1 0%']],
|
|
3215
|
+
'flex-auto': [['flex', '1 1 auto']],
|
|
3216
|
+
'flex-none': [['flex', 'none']],
|
|
3217
|
+
grow: [['flex-grow', '1']],
|
|
3218
|
+
'grow-0': [['flex-grow', '0']],
|
|
3219
|
+
shrink: [['flex-shrink', '1']],
|
|
3220
|
+
'shrink-0': [['flex-shrink', '0']],
|
|
3221
|
+
}
|
|
3222
|
+
if (direct[base]) return direct[base]
|
|
3223
|
+
|
|
3224
|
+
const alignMap = {
|
|
3225
|
+
start: 'flex-start',
|
|
3226
|
+
end: 'flex-end',
|
|
3227
|
+
center: 'center',
|
|
3228
|
+
baseline: 'baseline',
|
|
3229
|
+
stretch: 'stretch',
|
|
3230
|
+
}
|
|
3231
|
+
const justifyMap = {
|
|
3232
|
+
start: 'flex-start',
|
|
3233
|
+
end: 'flex-end',
|
|
3234
|
+
center: 'center',
|
|
3235
|
+
between: 'space-between',
|
|
3236
|
+
around: 'space-around',
|
|
3237
|
+
evenly: 'space-evenly',
|
|
3238
|
+
stretch: 'stretch',
|
|
3239
|
+
}
|
|
3240
|
+
const placeMap = { center: 'center', start: 'start', end: 'end', stretch: 'stretch' }
|
|
3241
|
+
let match = base.match(/^items-(start|end|center|baseline|stretch)$/)
|
|
3242
|
+
if (match) return [['align-items', alignMap[match[1]]]]
|
|
3243
|
+
match = base.match(/^justify-(start|end|center|between|around|evenly|stretch)$/)
|
|
3244
|
+
if (match) return [['justify-content', justifyMap[match[1]]]]
|
|
3245
|
+
match = base.match(/^content-(start|end|center|between|around|evenly|stretch)$/)
|
|
3246
|
+
if (match) return [['align-content', justifyMap[match[1]]]]
|
|
3247
|
+
match = base.match(/^self-(auto|start|end|center|stretch|baseline)$/)
|
|
3248
|
+
if (match) return [['align-self', match[1] === 'start' ? 'flex-start' : match[1] === 'end' ? 'flex-end' : match[1]]]
|
|
3249
|
+
match = base.match(/^place-(items|content|self)-(center|start|end|stretch)$/)
|
|
3250
|
+
if (match) return [[`place-${match[1]}`, placeMap[match[2]]]]
|
|
3251
|
+
|
|
3252
|
+
match = base.match(/^grid-cols-(\d+)$/)
|
|
3253
|
+
if (match) return [['grid-template-columns', `repeat(${match[1]}, minmax(0, 1fr))`]]
|
|
3254
|
+
match = base.match(/^grid-rows-(\d+)$/)
|
|
3255
|
+
if (match) return [['grid-template-rows', `repeat(${match[1]}, minmax(0, 1fr))`]]
|
|
3256
|
+
match = base.match(/^col-span-(\d+)$/)
|
|
3257
|
+
if (match) return [['grid-column', `span ${match[1]} / span ${match[1]}`]]
|
|
3258
|
+
match = base.match(/^row-span-(\d+)$/)
|
|
3259
|
+
if (match) return [['grid-row', `span ${match[1]} / span ${match[1]}`]]
|
|
3260
|
+
if (base === 'col-span-full') return [['grid-column', '1 / -1']]
|
|
3261
|
+
if (base === 'grid-flow-col') return [['grid-auto-flow', 'column']]
|
|
3262
|
+
if (base === 'grid-flow-row') return [['grid-auto-flow', 'row']]
|
|
3263
|
+
|
|
3264
|
+
return null
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
function typographyUtility(base) {
|
|
3268
|
+
let match = base.match(/^font-(sans|display|mono)$/)
|
|
3269
|
+
if (match) {
|
|
3270
|
+
const map = {
|
|
3271
|
+
sans: 'var(--font-inter, var(--sf-font-sans, ui-sans-serif, system-ui, sans-serif))',
|
|
3272
|
+
display: 'var(--font-display, var(--sf-font-display, ui-serif, Georgia, serif))',
|
|
3273
|
+
mono: 'var(--font-mono, var(--sf-font-mono, ui-monospace, monospace))',
|
|
3274
|
+
}
|
|
3275
|
+
return [['font-family', map[match[1]]]]
|
|
3276
|
+
}
|
|
3277
|
+
match = base.match(/^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/)
|
|
3278
|
+
if (match) return [['font-weight', String(fontWeightMap[match[1]])]]
|
|
3279
|
+
match = base.match(/^text-(left|center|right|justify|start|end)$/)
|
|
3280
|
+
if (match) return [['text-align', match[1]]]
|
|
3281
|
+
match = base.match(/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl)$/)
|
|
3282
|
+
if (match) return [['font-size', fontSizeMap[match[1]]]]
|
|
3283
|
+
match = base.match(/^leading-(.+)$/)
|
|
3284
|
+
if (match && lineHeightMap[match[1]]) return [['line-height', lineHeightMap[match[1]]]]
|
|
3285
|
+
match = base.match(/^tracking-(tighter|tight|normal|wide|wider|widest)$/)
|
|
3286
|
+
if (match) {
|
|
3287
|
+
const map = { tighter: '-0.05em', tight: '-0.025em', normal: '0', wide: '0.025em', wider: '0.05em', widest: '0.1em' }
|
|
3288
|
+
return [['letter-spacing', map[match[1]]]]
|
|
3289
|
+
}
|
|
3290
|
+
match = base.match(/^list-(none|disc|decimal)$/)
|
|
3291
|
+
if (match) return [['list-style-type', match[1]]]
|
|
3292
|
+
if (base === 'list-inside') return [['list-style-position', 'inside']]
|
|
3293
|
+
if (base === 'list-outside') return [['list-style-position', 'outside']]
|
|
3294
|
+
match = base.match(/^underline-offset-(\d+)$/)
|
|
3295
|
+
if (match) return [['text-underline-offset', spacingUnit(match[1])]]
|
|
3296
|
+
return null
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
function colourUtility(base) {
|
|
3300
|
+
const match = base.match(/^(bg|text|border|ring|fill|stroke|accent|from|via|to)-(.+)$/)
|
|
3301
|
+
if (!match) return null
|
|
3302
|
+
const [, prefix, name] = match
|
|
3303
|
+
const value = colourValue(name)
|
|
3304
|
+
if (!value) return null
|
|
3305
|
+
if (prefix === 'bg') return [['background-color', value]]
|
|
3306
|
+
if (prefix === 'text') return [['color', value]]
|
|
3307
|
+
if (prefix === 'border') return [['border-color', value]]
|
|
3308
|
+
if (prefix === 'ring') return [['--sf-ring-color', value]]
|
|
3309
|
+
if (prefix === 'fill') return [['fill', value]]
|
|
3310
|
+
if (prefix === 'stroke') return [['stroke', value]]
|
|
3311
|
+
if (prefix === 'accent') return [['accent-color', value]]
|
|
3312
|
+
if (prefix === 'from') return [['--sf-gradient-from', value], ['--sf-gradient-stops', 'var(--sf-gradient-from), var(--sf-gradient-to, transparent)']]
|
|
3313
|
+
if (prefix === 'via') return [['--sf-gradient-via', value], ['--sf-gradient-stops', 'var(--sf-gradient-from), var(--sf-gradient-via), var(--sf-gradient-to, transparent)']]
|
|
3314
|
+
if (prefix === 'to') return [['--sf-gradient-to', value]]
|
|
3315
|
+
return null
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
function borderUtility(base) {
|
|
3319
|
+
if (base === 'border') return [['border-width', '1px']]
|
|
3320
|
+
if (base === 'border-0') return [['border-width', '0']]
|
|
3321
|
+
if (base === 'border-2') return [['border-width', '2px']]
|
|
3322
|
+
if (base === 'border-t') return [['border-top-width', '1px']]
|
|
3323
|
+
if (base === 'border-r') return [['border-right-width', '1px']]
|
|
3324
|
+
if (base === 'border-b') return [['border-bottom-width', '1px']]
|
|
3325
|
+
if (base === 'border-l') return [['border-left-width', '1px']]
|
|
3326
|
+
if (base === 'border-y') return [['border-top-width', '1px'], ['border-bottom-width', '1px']]
|
|
3327
|
+
if (base === 'border-x') return [['border-left-width', '1px'], ['border-right-width', '1px']]
|
|
3328
|
+
const sideWidth = base.match(/^border-(t|r|b|l)-(\d+)$/)
|
|
3329
|
+
if (sideWidth) {
|
|
3330
|
+
const side = { t: 'top', r: 'right', b: 'bottom', l: 'left' }[sideWidth[1]]
|
|
3331
|
+
return [[`border-${side}-width`, `${sideWidth[2]}px`]]
|
|
3332
|
+
}
|
|
3333
|
+
if (base === 'divide-y') return [['--sf-divide-y', '1px']]
|
|
3334
|
+
if (base === 'divide-x') return [['--sf-divide-x', '1px']]
|
|
3335
|
+
const roundedSide = base.match(/^rounded-(t|r|b|l|tr|tl|br|bl)-(none|xs|sm|md|lg|xl|2xl|3xl|full)$/)
|
|
3336
|
+
if (roundedSide) {
|
|
3337
|
+
const radius = `var(--radius-${roundedSide[2]})`
|
|
3338
|
+
const sides = {
|
|
3339
|
+
t: ['border-top-left-radius', 'border-top-right-radius'],
|
|
3340
|
+
r: ['border-top-right-radius', 'border-bottom-right-radius'],
|
|
3341
|
+
b: ['border-bottom-right-radius', 'border-bottom-left-radius'],
|
|
3342
|
+
l: ['border-top-left-radius', 'border-bottom-left-radius'],
|
|
3343
|
+
tr: ['border-top-right-radius'],
|
|
3344
|
+
tl: ['border-top-left-radius'],
|
|
3345
|
+
br: ['border-bottom-right-radius'],
|
|
3346
|
+
bl: ['border-bottom-left-radius'],
|
|
3347
|
+
}
|
|
3348
|
+
return sides[roundedSide[1]].map((property) => [property, radius])
|
|
3349
|
+
}
|
|
3350
|
+
const match = base.match(/^rounded(?:-(none|xs|sm|md|lg|xl|2xl|3xl|full|t|r|b|l|tr|tl|br|bl))?$/)
|
|
3351
|
+
if (match) {
|
|
3352
|
+
const part = match[1]
|
|
3353
|
+
if (!part) return [['border-radius', 'var(--radius-md)']]
|
|
3354
|
+
if (fluidConfig.radii[part]) return [['border-radius', `var(--radius-${part})`]]
|
|
3355
|
+
const radius = 'var(--radius-md)'
|
|
3356
|
+
const sides = {
|
|
3357
|
+
t: ['border-top-left-radius', 'border-top-right-radius'],
|
|
3358
|
+
r: ['border-top-right-radius', 'border-bottom-right-radius'],
|
|
3359
|
+
b: ['border-bottom-right-radius', 'border-bottom-left-radius'],
|
|
3360
|
+
l: ['border-top-left-radius', 'border-bottom-left-radius'],
|
|
3361
|
+
tr: ['border-top-right-radius'],
|
|
3362
|
+
tl: ['border-top-left-radius'],
|
|
3363
|
+
br: ['border-bottom-right-radius'],
|
|
3364
|
+
bl: ['border-bottom-left-radius'],
|
|
3365
|
+
}
|
|
3366
|
+
return sides[part].map((property) => [property, radius])
|
|
3367
|
+
}
|
|
3368
|
+
return null
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
function effectUtility(base) {
|
|
3372
|
+
if (base === 'bg-gradient-to-r') return [['background-image', 'linear-gradient(to right, var(--sf-gradient-stops))']]
|
|
3373
|
+
if (base === 'bg-gradient-to-l') return [['background-image', 'linear-gradient(to left, var(--sf-gradient-stops))']]
|
|
3374
|
+
if (base === 'bg-gradient-to-t') return [['background-image', 'linear-gradient(to top, var(--sf-gradient-stops))']]
|
|
3375
|
+
if (base === 'bg-gradient-to-b') return [['background-image', 'linear-gradient(to bottom, var(--sf-gradient-stops))']]
|
|
3376
|
+
if (base === 'bg-gradient-to-br') return [['background-image', 'linear-gradient(to bottom right, var(--sf-gradient-stops))']]
|
|
3377
|
+
if (base === 'bg-gradient-to-bl') return [['background-image', 'linear-gradient(to bottom left, var(--sf-gradient-stops))']]
|
|
3378
|
+
if (base === 'bg-gradient-to-tr') return [['background-image', 'linear-gradient(to top right, var(--sf-gradient-stops))']]
|
|
3379
|
+
if (base === 'bg-gradient-to-tl') return [['background-image', 'linear-gradient(to top left, var(--sf-gradient-stops))']]
|
|
3380
|
+
if (base === 'shadow-sm') return [['box-shadow', 'var(--shadow-sm)']]
|
|
3381
|
+
if (base === 'shadow') return [['box-shadow', 'var(--shadow-md)']]
|
|
3382
|
+
if (base === 'shadow-md') return [['box-shadow', 'var(--shadow-md)']]
|
|
3383
|
+
if (base === 'shadow-lg') return [['box-shadow', 'var(--shadow-lg)']]
|
|
3384
|
+
if (base === 'shadow-xl') return [['box-shadow', 'var(--shadow-xl)']]
|
|
3385
|
+
if (base === 'shadow-2xl') return [['box-shadow', 'var(--shadow-2xl)']]
|
|
3386
|
+
if (base === 'shadow-none') return [['box-shadow', 'none']]
|
|
3387
|
+
if (base === 'shadow-primary-cta') return [['box-shadow', 'var(--shadow-primary-cta)']]
|
|
3388
|
+
if (base === 'shadow-glow') return [['box-shadow', 'var(--shadow-glow)']]
|
|
3389
|
+
if (base.startsWith('shadow-')) return [['--sf-shadow-color', colourValue(base.slice(7)) ?? 'currentColor']]
|
|
3390
|
+
|
|
3391
|
+
let match = base.match(/^opacity-(\d+)$/)
|
|
3392
|
+
if (match) return [['opacity', String(Number(match[1]) / 100)]]
|
|
3393
|
+
match = base.match(/^blur-(none|sm|md|lg|xl|2xl|3xl)$/)
|
|
3394
|
+
if (match) {
|
|
3395
|
+
const map = { none: '0', sm: '4px', md: '12px', lg: '16px', xl: '24px', '2xl': '40px', '3xl': '64px' }
|
|
3396
|
+
return [['filter', `blur(${map[match[1]]})`]]
|
|
3397
|
+
}
|
|
3398
|
+
match = base.match(/^backdrop-blur(?:-(none|sm|md|lg|xl))?$/)
|
|
3399
|
+
if (match) {
|
|
3400
|
+
const map = { undefined: '8px', none: '0', sm: '4px', md: '12px', lg: '16px', xl: '24px' }
|
|
3401
|
+
return [['backdrop-filter', `blur(${map[match[1] ?? 'undefined']})`]]
|
|
3402
|
+
}
|
|
3403
|
+
match = base.match(/^ring(?:-(\d+))?$/)
|
|
3404
|
+
if (match) return [['box-shadow', `0 0 0 ${match[1] ?? 3}px var(--sf-ring-color, var(--color-ring))`]]
|
|
3405
|
+
if (base === 'ring-offset-white') return [['--sf-ring-offset-color', 'var(--color-white)']]
|
|
3406
|
+
if (base === 'ring-offset-background') return [['--sf-ring-offset-color', 'var(--color-background)']]
|
|
3407
|
+
match = base.match(/^ring-offset-(\d+)$/)
|
|
3408
|
+
if (match) return [['--sf-ring-offset-width', `${match[1]}px`]]
|
|
3409
|
+
if (base === 'outline') return [['outline-style', 'solid']]
|
|
3410
|
+
if (base === 'outline-none') return [['outline', '2px solid transparent'], ['outline-offset', '2px']]
|
|
3411
|
+
match = base.match(/^outline-(\d+)$/)
|
|
3412
|
+
if (match) return [['outline-width', `${match[1]}px`]]
|
|
3413
|
+
match = base.match(/^outline-offset-(\d+)$/)
|
|
3414
|
+
if (match) return [['outline-offset', `${match[1]}px`]]
|
|
3415
|
+
match = base.match(/^outline-(.+)$/)
|
|
3416
|
+
if (match) {
|
|
3417
|
+
const value = colourValue(match[1])
|
|
3418
|
+
if (value) return [['outline-color', value]]
|
|
3419
|
+
}
|
|
3420
|
+
if (base === 'transition') return [['transition-property', 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
|
|
3421
|
+
if (base === 'transition-all') return [['transition-property', 'all'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
|
|
3422
|
+
if (base === 'transition-colors') return [['transition-property', 'color, background-color, border-color, text-decoration-color, fill, stroke'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
|
|
3423
|
+
if (base === 'transition-transform') return [['transition-property', 'transform'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
|
|
3424
|
+
match = base.match(/^transition-\[(.+)\]$/)
|
|
3425
|
+
if (match) return [['transition-property', arbitraryValue(match[1])], ['transition-duration', 'var(--duration-normal)']]
|
|
3426
|
+
match = base.match(/^duration-(\d+)$/)
|
|
3427
|
+
if (match) return [['transition-duration', `${match[1]}ms`]]
|
|
3428
|
+
match = base.match(/^delay-(\d+)$/)
|
|
3429
|
+
if (match) return [['transition-delay', `${match[1]}ms`]]
|
|
3430
|
+
if (base === 'ease-out') return [['transition-timing-function', 'cubic-bezier(0, 0, 0.2, 1)']]
|
|
3431
|
+
if (base === 'ease-in') return [['transition-timing-function', 'cubic-bezier(0.4, 0, 1, 1)']]
|
|
3432
|
+
if (base === 'ease-in-out') return [['transition-timing-function', 'cubic-bezier(0.4, 0, 0.2, 1)']]
|
|
3433
|
+
if (base === 'animate-pulse') return [['animation', 'sf-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite']]
|
|
3434
|
+
if (base === 'animate-spin') return [['animation', 'sf-spin 1s linear infinite']]
|
|
3435
|
+
if (base === 'animate-in') return [['animation', 'sf-enter var(--duration-normal) var(--ease-standard) both']]
|
|
3436
|
+
if (base === 'animate-out') return [['animation', 'sf-exit var(--duration-fast) var(--ease-standard) both']]
|
|
3437
|
+
return null
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
function transformUtility(base) {
|
|
3441
|
+
const declarations = []
|
|
3442
|
+
let match = base.match(/^(-?)translate-x-(.+)$/)
|
|
3443
|
+
if (match) declarations.push(['--sf-translate-x', `${match[1] ? '-' : ''}${spacingUnit(match[2]) ?? sizeKeyword(match[2])}`])
|
|
3444
|
+
match = base.match(/^(-?)translate-y-(.+)$/)
|
|
3445
|
+
if (match) declarations.push(['--sf-translate-y', `${match[1] ? '-' : ''}${spacingUnit(match[2]) ?? sizeKeyword(match[2])}`])
|
|
3446
|
+
match = base.match(/^scale-\[(.+)\]$/)
|
|
3447
|
+
if (match) declarations.push(['--sf-scale-x', arbitraryValue(match[1])], ['--sf-scale-y', arbitraryValue(match[1])])
|
|
3448
|
+
match = base.match(/^scale-(\d+)$/)
|
|
3449
|
+
if (match) declarations.push(['--sf-scale-x', String(Number(match[1]) / 100)], ['--sf-scale-y', String(Number(match[1]) / 100)])
|
|
3450
|
+
match = base.match(/^(-?)rotate-(\d+)$/)
|
|
3451
|
+
if (match) declarations.push(['--sf-rotate', `${match[1] ? '-' : ''}${match[2]}deg`])
|
|
3452
|
+
if (base === 'transform' || declarations.length) {
|
|
3453
|
+
return [
|
|
3454
|
+
...declarations,
|
|
3455
|
+
['transform', 'translate3d(var(--sf-translate-x, 0), var(--sf-translate-y, 0), 0) rotate(var(--sf-rotate, 0)) scaleX(var(--sf-scale-x, 1)) scaleY(var(--sf-scale-y, 1))'],
|
|
3456
|
+
]
|
|
3457
|
+
}
|
|
3458
|
+
return null
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
function cssRuleFor(token) {
|
|
3462
|
+
const { variants, base } = splitVariants(token)
|
|
3463
|
+
const declarations = declarationsFor(base)
|
|
3464
|
+
if (!declarations) return null
|
|
3465
|
+
if (declarations.length === 0) return ''
|
|
3466
|
+
|
|
3467
|
+
const selectorResult = selectorFor(token, variants)
|
|
3468
|
+
if (!selectorResult) return null
|
|
3469
|
+
|
|
3470
|
+
const { selector, wrappers, needsPseudoContent } = selectorResult
|
|
3471
|
+
const allDeclarations = needsPseudoContent ? [['content', '""'], ...declarations] : declarations
|
|
3472
|
+
let rule = `${selector}{${allDeclarations.map(([property, value]) => `${property}:${value}`).join(';')}}`
|
|
3473
|
+
|
|
3474
|
+
for (const wrapper of wrappers.reverse()) {
|
|
3475
|
+
const atRule = wrapper.type === 'container' ? '@container' : '@media'
|
|
3476
|
+
rule = `${atRule} ${wrapper.condition}{${rule}}`
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
return rule
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
function cssTokenName(value) {
|
|
3483
|
+
return value
|
|
3484
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
3485
|
+
.replace(/([a-z])(\d)/g, '$1-$2')
|
|
3486
|
+
.replace(/[^a-zA-Z0-9-]/g, '-')
|
|
3487
|
+
.toLowerCase()
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
function buildThemeCss(theme) {
|
|
3491
|
+
if (!theme || typeof theme !== 'object') return ''
|
|
3492
|
+
|
|
3493
|
+
const root = []
|
|
3494
|
+
const dark = []
|
|
3495
|
+
|
|
3496
|
+
for (const [name, value] of Object.entries(theme.fonts ?? {})) {
|
|
3497
|
+
const token = cssTokenName(name)
|
|
3498
|
+
root.push(` --sf-font-${token}: ${value};`)
|
|
3499
|
+
root.push(` --font-${token}: var(--sf-font-${token});`)
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
for (const [name, value] of Object.entries(theme.colours ?? {})) {
|
|
3503
|
+
const token = cssTokenName(name)
|
|
3504
|
+
root.push(` --sf-colour-${token}: ${value};`)
|
|
3505
|
+
root.push(` --color-${token}: var(--sf-colour-${token});`)
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
for (const [name, value] of Object.entries(theme.darkColours ?? {})) {
|
|
3509
|
+
const token = cssTokenName(name)
|
|
3510
|
+
dark.push(` --sf-colour-${token}: ${value};`)
|
|
3511
|
+
dark.push(` --color-${token}: var(--sf-colour-${token});`)
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
for (const [name, value] of Object.entries(theme.radii ?? {})) {
|
|
3515
|
+
const token = cssTokenName(name)
|
|
3516
|
+
root.push(` --sf-radius-${token}: ${value};`)
|
|
3517
|
+
root.push(` --radius-${token}: var(--sf-radius-${token});`)
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
if (theme.layout?.containerMax) {
|
|
3521
|
+
root.push(` --sf-container-max: ${theme.layout.containerMax};`)
|
|
3522
|
+
root.push(` --grid-max-width: ${theme.layout.containerMax};`)
|
|
3523
|
+
}
|
|
3524
|
+
if (theme.layout?.gutter) {
|
|
3525
|
+
root.push(` --sf-gutter: ${theme.layout.gutter};`)
|
|
3526
|
+
root.push(` --grid-gutter: ${theme.layout.gutter};`)
|
|
3527
|
+
}
|
|
3528
|
+
if (theme.layout?.columns) {
|
|
3529
|
+
root.push(` --sf-grid-columns: ${theme.layout.columns};`)
|
|
3530
|
+
root.push(` --grid-columns: ${theme.layout.columns};`)
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
const { button, card, input } = theme.components ?? {}
|
|
3534
|
+
if (button?.radius) {
|
|
3535
|
+
root.push(` --sf-button-radius: ${button.radius};`)
|
|
3536
|
+
root.push(` --button-radius: ${button.radius};`)
|
|
3537
|
+
}
|
|
3538
|
+
if (button?.blockSize) {
|
|
3539
|
+
root.push(` --sf-button-block-size: ${button.blockSize};`)
|
|
3540
|
+
root.push(` --button-size: ${button.blockSize};`)
|
|
3541
|
+
}
|
|
3542
|
+
if (button?.paddingInline) {
|
|
3543
|
+
root.push(` --sf-button-padding-inline: ${button.paddingInline};`)
|
|
3544
|
+
root.push(` --button-padding-inline: ${button.paddingInline};`)
|
|
3545
|
+
}
|
|
3546
|
+
if (card?.radius) {
|
|
3547
|
+
root.push(` --sf-card-radius: ${card.radius};`)
|
|
3548
|
+
root.push(` --card-radius: ${card.radius};`)
|
|
3549
|
+
}
|
|
3550
|
+
if (card?.padding) root.push(` --sf-card-padding: ${card.padding};`)
|
|
3551
|
+
if (card?.shadow) root.push(` --card-shadow: ${card.shadow};`)
|
|
3552
|
+
if (input?.radius) root.push(` --sf-input-radius: ${input.radius};`)
|
|
3553
|
+
if (input?.blockSize) root.push(` --sf-input-block-size: ${input.blockSize};`)
|
|
3554
|
+
|
|
3555
|
+
if (!root.length && !dark.length) return ''
|
|
3556
|
+
|
|
3557
|
+
const sections = []
|
|
3558
|
+
if (root.length) {
|
|
3559
|
+
sections.push(` :root {\n${root.join('\n')}\n }`)
|
|
3560
|
+
}
|
|
3561
|
+
if (dark.length) {
|
|
3562
|
+
sections.push(` :where(.sf-theme-dark, [data-sf-theme="dark"]) {\n${dark.join('\n')}\n }`)
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
return `@layer tokens {\n${sections.join('\n\n')}\n}`
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
function buildTokensCss() {
|
|
3569
|
+
const steps = []
|
|
3570
|
+
for (let step = fluidConfig.typeMinStep; step <= 8; step += 1) {
|
|
3571
|
+
steps.push(` --step-${step < 0 ? `-${Math.abs(step)}` : step}: ${typeStep(step)};`)
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
const spaceMultipliers = {
|
|
3575
|
+
'3xs': 0.25,
|
|
3576
|
+
'2xs': 0.5,
|
|
3577
|
+
xs: 0.75,
|
|
3578
|
+
s: 1,
|
|
3579
|
+
m: 1.5,
|
|
3580
|
+
l: 2,
|
|
3581
|
+
xl: 3,
|
|
3582
|
+
'2xl': 4,
|
|
3583
|
+
'3xl': 6,
|
|
3584
|
+
'4xl': 8,
|
|
3585
|
+
}
|
|
3586
|
+
const spaces = Object.entries(spaceMultipliers).map(([name, multiplier]) => {
|
|
3587
|
+
return ` --space-${name}: ${fluidClamp(fluidConfig.minRoot * multiplier, fluidConfig.maxRoot * multiplier)};`
|
|
3588
|
+
})
|
|
3589
|
+
const pairs = [
|
|
3590
|
+
['3xs', '2xs'],
|
|
3591
|
+
['2xs', 'xs'],
|
|
3592
|
+
['xs', 's'],
|
|
3593
|
+
['s', 'm'],
|
|
3594
|
+
['m', 'l'],
|
|
3595
|
+
['l', 'xl'],
|
|
3596
|
+
['xl', '2xl'],
|
|
3597
|
+
['2xl', '3xl'],
|
|
3598
|
+
['s', 'l'],
|
|
3599
|
+
['m', 'xl'],
|
|
3600
|
+
['l', '2xl'],
|
|
3601
|
+
].map(([minName, maxName]) => {
|
|
3602
|
+
return ` --space-${minName}-${maxName}: ${fluidClamp(fluidConfig.minRoot * spaceMultipliers[minName], fluidConfig.maxRoot * spaceMultipliers[maxName])};`
|
|
3603
|
+
})
|
|
3604
|
+
|
|
3605
|
+
const colours = fluidConfig.colours
|
|
3606
|
+
|
|
3607
|
+
return `@layer tokens {
|
|
3608
|
+
:root {
|
|
3609
|
+
color-scheme: light;
|
|
3610
|
+
--font-sans: var(--sf-font-sans, ui-sans-serif, system-ui, sans-serif);
|
|
3611
|
+
--font-display: var(--sf-font-display, ui-serif, Georgia, serif);
|
|
3612
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
3613
|
+
|
|
3614
|
+
${steps.join('\n')}
|
|
3615
|
+
|
|
3616
|
+
${spaces.join('\n')}
|
|
3617
|
+
${pairs.join('\n')}
|
|
3618
|
+
|
|
3619
|
+
--grid-max-width: ${fluidConfig.gridMaxWidthRem}rem;
|
|
3620
|
+
--grid-gutter: var(--space-s-l);
|
|
3621
|
+
--grid-columns: ${fluidConfig.gridColumns};
|
|
3622
|
+
|
|
3623
|
+
--radius-none: ${fluidConfig.radii.none};
|
|
3624
|
+
--radius-xs: ${fluidConfig.radii.xs};
|
|
3625
|
+
--radius-sm: ${fluidConfig.radii.sm};
|
|
3626
|
+
--radius-md: ${fluidConfig.radii.md};
|
|
3627
|
+
--radius-lg: ${fluidConfig.radii.lg};
|
|
3628
|
+
--radius-xl: ${fluidConfig.radii.xl};
|
|
3629
|
+
--radius-2xl: ${fluidConfig.radii['2xl']};
|
|
3630
|
+
--radius-3xl: ${fluidConfig.radii['3xl']};
|
|
3631
|
+
--radius-full: ${fluidConfig.radii.full};
|
|
3632
|
+
--radius: var(--radius-xl);
|
|
3633
|
+
|
|
3634
|
+
--color-ink-950: ${colours.ink950};
|
|
3635
|
+
--color-ink-900: ${colours.ink900};
|
|
3636
|
+
--color-ink-800: ${colours.ink800};
|
|
3637
|
+
--color-ink-700: ${colours.ink700};
|
|
3638
|
+
--color-ink-600: ${colours.ink600};
|
|
3639
|
+
--color-ink-500: ${colours.ink500};
|
|
3640
|
+
--color-paper: ${colours.paper};
|
|
3641
|
+
--color-paper-alt: ${colours.paperAlt};
|
|
3642
|
+
--color-white: ${colours.white};
|
|
3643
|
+
--color-black: ${colours.black};
|
|
3644
|
+
|
|
3645
|
+
--color-background: ${colours.ink950};
|
|
3646
|
+
--color-foreground: oklch(96% 0.01 92);
|
|
3647
|
+
--color-card: ${colours.ink900};
|
|
3648
|
+
--color-card-foreground: oklch(96% 0.01 92);
|
|
3649
|
+
--color-popover: ${colours.ink900};
|
|
3650
|
+
--color-popover-foreground: oklch(96% 0.01 92);
|
|
3651
|
+
--color-primary: ${colours.orange600};
|
|
3652
|
+
--color-primary-hover: oklch(64% 0.18 44);
|
|
3653
|
+
--color-primary-label: oklch(55% 0.18 44);
|
|
3654
|
+
--color-primary-border: oklch(66% 0.17 43);
|
|
3655
|
+
--color-primary-foreground: oklch(98% 0.01 95);
|
|
3656
|
+
--color-secondary: oklch(24% 0.018 22);
|
|
3657
|
+
--color-secondary-foreground: oklch(96% 0.01 92);
|
|
3658
|
+
--color-muted: oklch(26% 0.012 24);
|
|
3659
|
+
--color-muted-foreground: oklch(76% 0.01 92);
|
|
3660
|
+
--color-accent: oklch(24% 0.018 22);
|
|
3661
|
+
--color-accent-foreground: oklch(96% 0.01 92);
|
|
3662
|
+
--color-destructive: ${colours.red600};
|
|
3663
|
+
--color-destructive-foreground: oklch(98% 0.01 95);
|
|
3664
|
+
--color-border: oklch(100% 0 0 / 0.12);
|
|
3665
|
+
--color-input: oklch(100% 0 0 / 0.12);
|
|
3666
|
+
--color-ring: ${colours.orange600};
|
|
3667
|
+
--color-heading: oklch(20% 0.02 18);
|
|
3668
|
+
--color-page-text: oklch(23% 0.02 18);
|
|
3669
|
+
--color-body: oklch(45% 0.02 28);
|
|
3670
|
+
--color-body-dark: oklch(40% 0.02 28);
|
|
3671
|
+
--color-subdued: oklch(42% 0.02 20);
|
|
3672
|
+
--color-surface: ${colours.surfaceAlt};
|
|
3673
|
+
--color-surface-alt: ${colours.paperAlt};
|
|
3674
|
+
--color-hero-bg: oklch(8% 0.02 260);
|
|
3675
|
+
--color-green: ${colours.green600};
|
|
3676
|
+
--color-green-dark: ${colours.green700};
|
|
3677
|
+
--color-green-muted: oklch(40% 0.12 148);
|
|
3678
|
+
--color-green-light: ${colours.green100};
|
|
3679
|
+
--color-green-lighter: oklch(92% 0.04 148);
|
|
3680
|
+
--color-green-bg: oklch(88% 0.07 148);
|
|
3681
|
+
--color-green-bg-alt: oklch(82% 0.1 148);
|
|
3682
|
+
--color-green-vivid: oklch(78% 0.08 148);
|
|
3683
|
+
--color-green-mid: oklch(75% 0.12 148);
|
|
3684
|
+
--color-green-deep: oklch(70% 0.14 150);
|
|
3685
|
+
--color-green-deepest: oklch(60% 0.14 150);
|
|
3686
|
+
--color-green-wash: oklch(93% 0.06 148);
|
|
3687
|
+
--color-green-wash-alt: oklch(88% 0.08 148);
|
|
3688
|
+
--color-green-gradient-from: oklch(82% 0.1 148);
|
|
3689
|
+
--color-green-gradient-to: oklch(86% 0.06 148);
|
|
3690
|
+
--color-deco-lime: oklch(80% 0.18 130);
|
|
3691
|
+
--color-deco-blue: oklch(60% 0.12 240);
|
|
3692
|
+
--color-deco-warm: oklch(80% 0.06 60);
|
|
3693
|
+
--color-deco-warm-light: oklch(92% 0.02 80);
|
|
3694
|
+
--color-deco-warm-mid: oklch(80% 0.15 60);
|
|
3695
|
+
--color-purple-light: oklch(95% 0.05 280);
|
|
3696
|
+
--color-purple-mid: oklch(92% 0.05 280);
|
|
3697
|
+
--color-purple-dark: oklch(90% 0.06 280);
|
|
3698
|
+
--color-purple-darker: oklch(87% 0.07 280);
|
|
3699
|
+
--color-purple-icon: ${colours.purple600};
|
|
3700
|
+
--color-warm-light: oklch(95.6% 0.05 22);
|
|
3701
|
+
--color-warm-mid: oklch(95% 0.04 40);
|
|
3702
|
+
--color-warm-grad-to: oklch(90% 0.08 30);
|
|
3703
|
+
--color-warm-grad-to-alt: oklch(90% 0.06 30);
|
|
3704
|
+
--color-warm-icon: oklch(60% 0.17 44);
|
|
3705
|
+
--color-cool-light: oklch(92% 0.04 200);
|
|
3706
|
+
--color-cool-dark: oklch(87% 0.06 200);
|
|
3707
|
+
--color-primary-glow: oklch(80% 0.12 44);
|
|
3708
|
+
--color-primary-soft: oklch(69% 0.18 44 / 0.10);
|
|
3709
|
+
--color-primary-soft-border: oklch(69% 0.18 44 / 0.30);
|
|
3710
|
+
--color-primary-soft-border-light: oklch(69% 0.18 44 / 0.25);
|
|
3711
|
+
--color-primary-soft-text: oklch(85% 0.08 44);
|
|
3712
|
+
--color-primary-highlight: oklch(75% 0.14 44);
|
|
3713
|
+
|
|
3714
|
+
--shadow-sm: 0 0.125rem 0.375rem oklch(0% 0 0 / 0.06);
|
|
3715
|
+
--shadow-md: 0 0.5rem 1.5rem oklch(0% 0 0 / 0.08);
|
|
3716
|
+
--shadow-lg: 0 1rem 2rem oklch(0% 0 0 / 0.1);
|
|
3717
|
+
--shadow-xl: 0 1.5rem 3rem oklch(0% 0 0 / 0.14);
|
|
3718
|
+
--shadow-2xl: 0 2rem 4rem oklch(0% 0 0 / 0.18);
|
|
3719
|
+
--shadow-primary-cta: 0 0.875rem 1.875rem oklch(69% 0.18 44 / 0.24);
|
|
3720
|
+
--shadow-glow: 0 0 2rem oklch(69% 0.18 44 / 0.28);
|
|
3721
|
+
|
|
3722
|
+
--duration-fast: 150ms;
|
|
3723
|
+
--duration-normal: 220ms;
|
|
3724
|
+
--duration-slow: 360ms;
|
|
3725
|
+
--ease-standard: cubic-bezier(.2, 0, 0, 1);
|
|
3726
|
+
--icon-size: 1em;
|
|
3727
|
+
--icon-stroke: 2;
|
|
3728
|
+
}
|
|
3729
|
+
}`
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
function buildBaseCss() {
|
|
3733
|
+
return `@layer reset {
|
|
3734
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
3735
|
+
*:where(:not(dialog)) { margin: 0; }
|
|
3736
|
+
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
|
3737
|
+
body { min-block-size: 100%; }
|
|
3738
|
+
img, picture, video, canvas, svg { display: block; max-inline-size: 100%; }
|
|
3739
|
+
img, video { block-size: auto; }
|
|
3740
|
+
input, button, textarea, select { font: inherit; }
|
|
3741
|
+
button, input:where([type="button"], [type="submit"], [type="reset"]) { appearance: button; }
|
|
3742
|
+
[hidden]:where(:not([hidden="until-found"])) { display: none !important; }
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
@layer base {
|
|
3746
|
+
body {
|
|
3747
|
+
background: var(--color-background);
|
|
3748
|
+
color: var(--color-foreground);
|
|
3749
|
+
font-family: var(--font-inter, var(--sf-font-sans, ui-sans-serif, system-ui, sans-serif));
|
|
3750
|
+
font-size: var(--step-0);
|
|
3751
|
+
line-height: 1.5;
|
|
3752
|
+
font-feature-settings: "rlig" 1, "calt" 1;
|
|
3753
|
+
-webkit-font-smoothing: antialiased;
|
|
3754
|
+
text-rendering: optimizeLegibility;
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
:where(h1, h2, h3, h4) {
|
|
3758
|
+
color: inherit;
|
|
3759
|
+
font-family: var(--font-display, var(--sf-font-display, ui-serif, Georgia, serif));
|
|
3760
|
+
text-wrap: balance;
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
:where(p, li) { text-wrap: pretty; }
|
|
3764
|
+
|
|
3765
|
+
:where(a) {
|
|
3766
|
+
color: inherit;
|
|
3767
|
+
text-decoration-color: color-mix(in oklch, currentColor 55%, transparent);
|
|
3768
|
+
text-decoration-skip-ink: auto;
|
|
3769
|
+
text-decoration-thickness: .08em;
|
|
3770
|
+
text-underline-offset: .18em;
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
:where(button:not(:disabled), [role="button"]:not(:disabled), a[href]) { cursor: pointer; }
|
|
3774
|
+
|
|
3775
|
+
:where(button:disabled, [aria-disabled="true"]) { cursor: not-allowed; }
|
|
3776
|
+
|
|
3777
|
+
:where(:focus-visible) {
|
|
3778
|
+
outline: 2px solid var(--color-ring);
|
|
3779
|
+
outline-offset: .2rem;
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
::selection {
|
|
3783
|
+
background: color-mix(in oklch, var(--color-primary) 24%, transparent);
|
|
3784
|
+
color: var(--color-card);
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3788
|
+
html { scroll-behavior: auto; }
|
|
3789
|
+
*, *::before, *::after {
|
|
3790
|
+
animation-duration: 0.01ms !important;
|
|
3791
|
+
animation-iteration-count: 1 !important;
|
|
3792
|
+
scroll-behavior: auto !important;
|
|
3793
|
+
transition-duration: 0.01ms !important;
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
}`
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
function buildDefaultsCss() {
|
|
3800
|
+
return `@layer app {
|
|
3801
|
+
:where(a) {
|
|
3802
|
+
color: inherit;
|
|
3803
|
+
text-decoration: none;
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
:where(ol, ul, menu) {
|
|
3807
|
+
list-style: none;
|
|
3808
|
+
padding-inline-start: 0;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
:where(button) {
|
|
3812
|
+
background: transparent;
|
|
3813
|
+
border: 0;
|
|
3814
|
+
color: inherit;
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
:where(fieldset) {
|
|
3818
|
+
border: 0;
|
|
3819
|
+
padding: 0;
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
:where(legend) { padding: 0; }
|
|
3823
|
+
}`
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
function buildLayoutCss() {
|
|
3827
|
+
return `@layer layout {
|
|
3828
|
+
.sf-container {
|
|
3829
|
+
inline-size: min(100% - (var(--grid-gutter) * 2), var(--grid-max-width));
|
|
3830
|
+
margin-inline: auto;
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
.sf-section {
|
|
3834
|
+
padding-block: var(--space-xl-2xl);
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
.sf-stack {
|
|
3838
|
+
display: flex;
|
|
3839
|
+
flex-direction: column;
|
|
3840
|
+
gap: var(--stack-space, var(--space-s));
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
.sf-cluster {
|
|
3844
|
+
display: flex;
|
|
3845
|
+
flex-wrap: wrap;
|
|
3846
|
+
align-items: var(--cluster-align, center);
|
|
3847
|
+
justify-content: var(--cluster-justify, flex-start);
|
|
3848
|
+
gap: var(--cluster-space, var(--space-s));
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
.sf-repel {
|
|
3852
|
+
display: flex;
|
|
3853
|
+
flex-wrap: wrap;
|
|
3854
|
+
align-items: center;
|
|
3855
|
+
justify-content: space-between;
|
|
3856
|
+
gap: var(--space-s);
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
.sf-toolbar {
|
|
3860
|
+
align-items: center;
|
|
3861
|
+
display: flex;
|
|
3862
|
+
flex-wrap: wrap;
|
|
3863
|
+
gap: var(--space-s);
|
|
3864
|
+
justify-content: space-between;
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
.sf-app-shell {
|
|
3868
|
+
background: var(--color-background);
|
|
3869
|
+
color: var(--color-foreground);
|
|
3870
|
+
display: grid;
|
|
3871
|
+
grid-template-columns: minmax(14rem, var(--sf-app-sidebar-width, 16rem)) minmax(0, 1fr);
|
|
3872
|
+
min-block-size: 100dvh;
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
.sf-app-sidebar {
|
|
3876
|
+
align-self: start;
|
|
3877
|
+
background: var(--color-surface);
|
|
3878
|
+
border-inline-end: 1px solid var(--color-border);
|
|
3879
|
+
display: flex;
|
|
3880
|
+
flex-direction: column;
|
|
3881
|
+
gap: var(--space-s);
|
|
3882
|
+
max-block-size: 100dvh;
|
|
3883
|
+
min-block-size: 100dvh;
|
|
3884
|
+
overflow: auto;
|
|
3885
|
+
padding: var(--space-s);
|
|
3886
|
+
position: sticky;
|
|
3887
|
+
inset-block-start: 0;
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
.sf-app-sidebar__brand {
|
|
3891
|
+
align-items: center;
|
|
3892
|
+
display: flex;
|
|
3893
|
+
gap: var(--space-xs);
|
|
3894
|
+
padding-block-end: var(--space-s);
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
.sf-app-sidebar .sf-nav {
|
|
3898
|
+
align-items: stretch;
|
|
3899
|
+
flex-direction: column;
|
|
3900
|
+
justify-content: flex-start;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
.sf-app-sidebar .sf-kicker {
|
|
3904
|
+
text-align: start;
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
.sf-app-sidebar .sf-nav__list {
|
|
3908
|
+
align-items: stretch;
|
|
3909
|
+
flex-direction: column;
|
|
3910
|
+
inline-size: 100%;
|
|
3911
|
+
margin-block: 0;
|
|
3912
|
+
padding-inline-start: var(--space-2xs);
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
.sf-app-sidebar .sf-nav__list > li {
|
|
3916
|
+
inline-size: 100%;
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
.sf-app-sidebar .sf-nav__link {
|
|
3920
|
+
--icon-size: var(--step-0);
|
|
3921
|
+
gap: var(--space-xs);
|
|
3922
|
+
inline-size: 100%;
|
|
3923
|
+
justify-content: flex-start;
|
|
3924
|
+
min-block-size: 2.5rem;
|
|
3925
|
+
padding-block: var(--space-2xs);
|
|
3926
|
+
padding-inline: var(--space-xs);
|
|
3927
|
+
text-align: start;
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
.sf-app-sidebar > footer {
|
|
3931
|
+
border-block-start: 1px solid var(--color-border);
|
|
3932
|
+
padding-block-start: var(--space-s);
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3935
|
+
.sf-app-main {
|
|
3936
|
+
min-inline-size: 0;
|
|
3937
|
+
padding: var(--sf-app-main-padding, var(--space-m-l));
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
.sf-grid {
|
|
3941
|
+
display: grid;
|
|
3942
|
+
gap: var(--grid-gutter);
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
.sf-auto-grid {
|
|
3946
|
+
display: grid;
|
|
3947
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--auto-grid-min, 16rem)), 1fr));
|
|
3948
|
+
gap: var(--grid-gutter);
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
.sf-split {
|
|
3952
|
+
display: grid;
|
|
3953
|
+
gap: var(--split-gap, var(--grid-gutter));
|
|
3954
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--split-min, 24rem)), 1fr));
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
.sf-centre {
|
|
3958
|
+
box-sizing: content-box;
|
|
3959
|
+
margin-inline: auto;
|
|
3960
|
+
max-inline-size: var(--centre-size, 65ch);
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
.sf-sidebar {
|
|
3964
|
+
display: flex;
|
|
3965
|
+
flex-wrap: wrap;
|
|
3966
|
+
gap: var(--grid-gutter);
|
|
3967
|
+
}
|
|
3968
|
+
|
|
3969
|
+
.sf-sidebar > :first-child {
|
|
3970
|
+
flex-basis: var(--sidebar-width, 20rem);
|
|
3971
|
+
flex-grow: 1;
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
.sf-sidebar > :last-child {
|
|
3975
|
+
flex-basis: 0;
|
|
3976
|
+
flex-grow: 999;
|
|
3977
|
+
min-inline-size: min(100%, var(--sidebar-content-min, 50%));
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
.sf-frame {
|
|
3981
|
+
aspect-ratio: var(--frame-ratio, 16 / 9);
|
|
3982
|
+
overflow: hidden;
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
.sf-frame > :where(img, video) {
|
|
3986
|
+
block-size: 100%;
|
|
3987
|
+
inline-size: 100%;
|
|
3988
|
+
object-fit: cover;
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
.sf-flow > * + * { margin-block-start: var(--flow-space, var(--space-s)); }
|
|
3992
|
+
|
|
3993
|
+
.sf-panel-grid {
|
|
3994
|
+
container-type: inline-size;
|
|
3995
|
+
display: grid;
|
|
3996
|
+
gap: var(--grid-gutter);
|
|
3997
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--panel-min, 18rem)), 1fr));
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
.sf-metric-grid,
|
|
4001
|
+
.sf-pipeline {
|
|
4002
|
+
display: grid;
|
|
4003
|
+
gap: var(--space-s);
|
|
4004
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--sf-panel-min, 11rem)), 1fr));
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
@media (max-width: 56rem) {
|
|
4008
|
+
.sf-app-shell {
|
|
4009
|
+
grid-template-columns: 1fr;
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
.sf-app-main {
|
|
4013
|
+
--sf-app-main-padding: var(--space-s);
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
.sf-app-sidebar {
|
|
4017
|
+
border-block-end: 1px solid var(--color-border);
|
|
4018
|
+
border-inline-end: 0;
|
|
4019
|
+
max-block-size: none;
|
|
4020
|
+
min-block-size: auto;
|
|
4021
|
+
position: static;
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
.sf-app-sidebar > footer {
|
|
4025
|
+
margin-block-start: 0;
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
|
|
4029
|
+
@container (min-width: 42rem) {
|
|
4030
|
+
.sf-panel-grid[data-density="featured"] > :first-child {
|
|
4031
|
+
grid-column: span 2;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
:where(.space-y-1, .space-y-2, .space-y-3, .space-y-4, .space-y-5, .space-y-6, .space-y-8, .space-y-10, .space-y-12) > * + * {
|
|
4036
|
+
margin-block-start: var(--sf-space-y);
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
:where(.space-x-1, .space-x-2, .space-x-3, .space-x-4, .space-x-5, .space-x-6, .space-x-8) > * + * {
|
|
4040
|
+
margin-inline-start: var(--sf-space-x);
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
.divide-y > * + * { border-block-start-width: var(--sf-divide-y); }
|
|
4044
|
+
.divide-x > * + * { border-inline-start-width: var(--sf-divide-x); }
|
|
4045
|
+
}`
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
function buildComponentCss() {
|
|
4049
|
+
return `@layer components {
|
|
4050
|
+
.sf-button {
|
|
4051
|
+
align-items: center;
|
|
4052
|
+
border-radius: var(--button-radius, var(--radius-md));
|
|
4053
|
+
display: inline-flex;
|
|
4054
|
+
font-size: var(--button-font-size, var(--step--1));
|
|
4055
|
+
font-weight: 700;
|
|
4056
|
+
gap: var(--space-2xs);
|
|
4057
|
+
justify-content: center;
|
|
4058
|
+
min-block-size: var(--button-size, 2.75rem);
|
|
4059
|
+
padding-block: var(--button-padding-block, .625rem);
|
|
4060
|
+
padding-inline: var(--button-padding-inline, var(--space-s));
|
|
4061
|
+
text-decoration: none;
|
|
4062
|
+
transition: background-color var(--duration-normal) var(--ease-standard), border-color var(--duration-normal) var(--ease-standard), color var(--duration-normal) var(--ease-standard), box-shadow var(--duration-normal) var(--ease-standard), transform var(--duration-normal) var(--ease-standard);
|
|
4063
|
+
white-space: nowrap;
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
.sf-button:disabled,
|
|
4067
|
+
.sf-button[aria-disabled="true"] {
|
|
4068
|
+
opacity: .55;
|
|
4069
|
+
pointer-events: none;
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
.sf-button--default {
|
|
4073
|
+
background: var(--button-bg, var(--color-primary));
|
|
4074
|
+
border: 1px solid var(--button-border, var(--color-primary-border));
|
|
4075
|
+
box-shadow: var(--shadow-primary-cta);
|
|
4076
|
+
color: var(--button-fg, var(--color-primary-foreground));
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
.sf-button--default:hover { background: var(--color-primary-hover); }
|
|
4080
|
+
.sf-button--outline { background: transparent; border: 1px solid color-mix(in oklch, var(--color-ink-950) 12%, transparent); color: var(--color-heading); }
|
|
4081
|
+
.sf-button--outline:hover { background: var(--color-surface); }
|
|
4082
|
+
.sf-button--secondary { background: var(--color-secondary); color: var(--color-secondary-foreground); }
|
|
4083
|
+
.sf-button--secondary:hover { background: color-mix(in oklch, var(--color-secondary) 80%, white); }
|
|
4084
|
+
.sf-button--ghost { background: transparent; color: var(--color-heading); }
|
|
4085
|
+
.sf-button--ghost:hover { background: var(--color-surface); }
|
|
4086
|
+
.sf-button--link { min-block-size: auto; padding: 0; color: var(--color-primary); text-decoration: underline; text-underline-offset: .25em; box-shadow: none; }
|
|
4087
|
+
.sf-button--destructive { background: var(--color-destructive); color: var(--color-destructive-foreground); }
|
|
4088
|
+
.sf-button--sm { --button-size: 2.25rem; --button-padding-inline: var(--space-xs); }
|
|
4089
|
+
.sf-button--lg { --button-size: 3rem; --button-padding-inline: var(--space-m); }
|
|
4090
|
+
.sf-button--icon,
|
|
4091
|
+
.sf-icon-button { --button-size: 2.75rem; inline-size: 2.75rem; padding-inline: 0; }
|
|
4092
|
+
|
|
4093
|
+
.sf-icon {
|
|
4094
|
+
block-size: var(--icon-size, 1em);
|
|
4095
|
+
color: var(--icon-color, currentColor);
|
|
4096
|
+
flex: 0 0 auto;
|
|
4097
|
+
inline-size: var(--icon-size, 1em);
|
|
4098
|
+
stroke-width: var(--icon-stroke, 2);
|
|
4099
|
+
vertical-align: -0.125em;
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
.sf-icon--xs { --icon-size: var(--step--1); }
|
|
4103
|
+
.sf-icon--sm { --icon-size: var(--step-0); }
|
|
4104
|
+
.sf-icon--md { --icon-size: var(--step-1); }
|
|
4105
|
+
.sf-icon--lg { --icon-size: var(--step-2); }
|
|
4106
|
+
.sf-icon--xl { --icon-size: var(--step-3); }
|
|
4107
|
+
|
|
4108
|
+
.sf-card {
|
|
4109
|
+
background: var(--card-bg, var(--color-card));
|
|
4110
|
+
border: 1px solid var(--card-border, var(--color-border));
|
|
4111
|
+
border-radius: var(--card-radius, var(--radius-lg));
|
|
4112
|
+
box-shadow: var(--card-shadow, var(--shadow-sm));
|
|
4113
|
+
color: var(--card-fg, var(--color-card-foreground));
|
|
4114
|
+
padding: var(--card-padding, var(--space-m-l));
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
.sf-card--interactive {
|
|
4118
|
+
transition: border-color var(--duration-normal) var(--ease-standard), box-shadow var(--duration-normal) var(--ease-standard), transform var(--duration-normal) var(--ease-standard);
|
|
4119
|
+
}
|
|
4120
|
+
|
|
4121
|
+
.sf-card--interactive:hover {
|
|
4122
|
+
border-color: color-mix(in oklch, var(--color-primary) 32%, var(--color-border));
|
|
4123
|
+
box-shadow: var(--shadow-md);
|
|
4124
|
+
transform: translateY(-0.125rem);
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
.sf-section-header {
|
|
4128
|
+
display: grid;
|
|
4129
|
+
gap: var(--space-xs);
|
|
4130
|
+
max-inline-size: var(--section-header-width, 54rem);
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
.sf-section-header[data-align="center"] {
|
|
4134
|
+
margin-inline: auto;
|
|
4135
|
+
text-align: center;
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
.sf-kicker {
|
|
4139
|
+
color: var(--color-primary);
|
|
4140
|
+
font-size: var(--step--1);
|
|
4141
|
+
font-weight: 800;
|
|
4142
|
+
letter-spacing: 0.08em;
|
|
4143
|
+
text-transform: uppercase;
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
.sf-badge {
|
|
4147
|
+
align-items: center;
|
|
4148
|
+
background: color-mix(in oklch, var(--color-primary) 12%, transparent);
|
|
4149
|
+
border: 1px solid color-mix(in oklch, var(--color-primary) 24%, transparent);
|
|
4150
|
+
border-radius: var(--radius-full);
|
|
4151
|
+
color: var(--color-primary);
|
|
4152
|
+
display: inline-flex;
|
|
4153
|
+
font-size: var(--step--1);
|
|
4154
|
+
font-weight: 700;
|
|
4155
|
+
gap: var(--space-3xs);
|
|
4156
|
+
line-height: 1;
|
|
4157
|
+
padding: .45em .75em;
|
|
4158
|
+
}
|
|
4159
|
+
|
|
4160
|
+
.sf-avatar {
|
|
4161
|
+
align-items: center;
|
|
4162
|
+
aspect-ratio: 1;
|
|
4163
|
+
background: color-mix(in oklch, var(--color-primary) 14%, transparent);
|
|
4164
|
+
border: 1px solid color-mix(in oklch, var(--color-primary) 28%, transparent);
|
|
4165
|
+
border-radius: var(--radius-full);
|
|
4166
|
+
color: var(--color-primary);
|
|
4167
|
+
display: inline-flex;
|
|
4168
|
+
flex: 0 0 auto;
|
|
4169
|
+
font-size: var(--step--1);
|
|
4170
|
+
font-weight: 800;
|
|
4171
|
+
inline-size: var(--avatar-size, 2.25rem);
|
|
4172
|
+
justify-content: center;
|
|
4173
|
+
line-height: 1;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
.sf-chart {
|
|
4177
|
+
--chart-accent: var(--color-primary);
|
|
4178
|
+
}
|
|
4179
|
+
|
|
4180
|
+
.sf-chart__plot {
|
|
4181
|
+
background: color-mix(in oklch, var(--color-surface) 72%, transparent);
|
|
4182
|
+
border: 1px solid var(--color-border);
|
|
4183
|
+
border-radius: var(--radius-lg);
|
|
4184
|
+
overflow: hidden;
|
|
4185
|
+
padding: var(--space-xs);
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
.sf-chart--wide {
|
|
4189
|
+
--chart-block-size: 14rem;
|
|
4190
|
+
}
|
|
4191
|
+
|
|
4192
|
+
.sf-chart__svg {
|
|
4193
|
+
block-size: var(--chart-block-size, 12rem);
|
|
4194
|
+
display: block;
|
|
4195
|
+
inline-size: 100%;
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
.sf-chart__grid,
|
|
4199
|
+
.sf-chart__axis {
|
|
4200
|
+
fill: none;
|
|
4201
|
+
stroke: var(--color-border);
|
|
4202
|
+
stroke-width: 1;
|
|
4203
|
+
vector-effect: non-scaling-stroke;
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
.sf-chart__area {
|
|
4207
|
+
fill: color-mix(in oklch, var(--chart-accent) 18%, transparent);
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
.sf-chart__line {
|
|
4211
|
+
fill: none;
|
|
4212
|
+
stroke: var(--chart-accent);
|
|
4213
|
+
stroke-linecap: round;
|
|
4214
|
+
stroke-linejoin: round;
|
|
4215
|
+
stroke-width: 3;
|
|
4216
|
+
vector-effect: non-scaling-stroke;
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
.sf-chart__point {
|
|
4220
|
+
fill: var(--chart-accent);
|
|
4221
|
+
stroke: var(--color-card);
|
|
4222
|
+
stroke-width: 2;
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
.sf-chart__legend {
|
|
4226
|
+
align-items: center;
|
|
4227
|
+
display: flex;
|
|
4228
|
+
flex-wrap: wrap;
|
|
4229
|
+
gap: var(--space-xs);
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4232
|
+
.sf-chart__legend-item {
|
|
4233
|
+
align-items: center;
|
|
4234
|
+
color: var(--color-muted-foreground);
|
|
4235
|
+
display: inline-flex;
|
|
4236
|
+
gap: var(--space-3xs);
|
|
4237
|
+
}
|
|
4238
|
+
|
|
4239
|
+
.sf-chart__swatch {
|
|
4240
|
+
aspect-ratio: 1;
|
|
4241
|
+
background: var(--chart-accent);
|
|
4242
|
+
border-radius: var(--radius-full);
|
|
4243
|
+
display: inline-block;
|
|
4244
|
+
inline-size: .75rem;
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
.sf-meter-list {
|
|
4248
|
+
display: grid;
|
|
4249
|
+
gap: var(--space-xs);
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
.sf-meter-row {
|
|
4253
|
+
display: grid;
|
|
4254
|
+
gap: var(--space-3xs);
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
.sf-meter {
|
|
4258
|
+
accent-color: var(--chart-accent, var(--color-primary));
|
|
4259
|
+
block-size: .625rem;
|
|
4260
|
+
border-radius: var(--radius-full);
|
|
4261
|
+
inline-size: 100%;
|
|
4262
|
+
overflow: hidden;
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
.sf-field {
|
|
4266
|
+
display: grid;
|
|
4267
|
+
gap: var(--space-2xs);
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
.sf-field > label {
|
|
4271
|
+
color: var(--color-muted-foreground);
|
|
4272
|
+
font-size: var(--step--1);
|
|
4273
|
+
font-weight: 700;
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
.sf-input {
|
|
4277
|
+
background: var(--color-card);
|
|
4278
|
+
border: 1px solid var(--color-border);
|
|
4279
|
+
border-radius: var(--input-radius, var(--radius-md));
|
|
4280
|
+
color: var(--color-foreground);
|
|
4281
|
+
min-block-size: var(--input-block-size, 2.75rem);
|
|
4282
|
+
padding-inline: var(--space-s);
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
.sf-input:focus-visible {
|
|
4286
|
+
border-color: var(--color-ring);
|
|
4287
|
+
outline: 0.125rem solid color-mix(in oklch, var(--color-ring) 24%, transparent);
|
|
4288
|
+
outline-offset: 0;
|
|
4289
|
+
}
|
|
4290
|
+
}`
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
function buildStaticUtilitiesCss() {
|
|
4294
|
+
return `@layer utilities {
|
|
4295
|
+
.sf-text-caption { font-size: var(--step--1); line-height: 1.35; }
|
|
4296
|
+
.sf-text-body { font-size: var(--step-0); line-height: 1.5; }
|
|
4297
|
+
.sf-text-lead { font-size: var(--step-1); line-height: 1.45; }
|
|
4298
|
+
.sf-text-h4 { font-size: var(--step-3); line-height: 1.1; }
|
|
4299
|
+
.sf-text-h3 { font-size: var(--step-4); line-height: 1.05; }
|
|
4300
|
+
.sf-text-h2 { font-size: var(--step-5); line-height: 1; }
|
|
4301
|
+
.sf-text-h1 { font-size: var(--step-6); line-height: 1; }
|
|
4302
|
+
.sf-text-display { font-size: var(--step-7); line-height: .95; }
|
|
4303
|
+
|
|
4304
|
+
.sf-prose {
|
|
4305
|
+
max-inline-size: var(--prose-width, 68ch);
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
:where(.sf-visually-hidden, .sr-only) {
|
|
4309
|
+
block-size: 0.0625rem;
|
|
4310
|
+
border: 0;
|
|
4311
|
+
clip: rect(0 0 0 0);
|
|
4312
|
+
clip-path: inset(50%);
|
|
4313
|
+
inline-size: 0.0625rem;
|
|
4314
|
+
margin: -0.0625rem;
|
|
4315
|
+
overflow: hidden;
|
|
4316
|
+
padding: 0;
|
|
4317
|
+
position: absolute;
|
|
4318
|
+
white-space: nowrap;
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
:where(.sf-not-visually-hidden, .not-sr-only) {
|
|
4322
|
+
block-size: auto;
|
|
4323
|
+
clip: auto;
|
|
4324
|
+
clip-path: none;
|
|
4325
|
+
inline-size: auto;
|
|
4326
|
+
margin: 0;
|
|
4327
|
+
overflow: visible;
|
|
4328
|
+
padding: 0;
|
|
4329
|
+
position: static;
|
|
4330
|
+
white-space: normal;
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4333
|
+
.sf-skip-link {
|
|
4334
|
+
background: var(--color-primary);
|
|
4335
|
+
border-radius: var(--radius-md);
|
|
4336
|
+
box-shadow: var(--shadow-md);
|
|
4337
|
+
color: var(--color-primary-foreground);
|
|
4338
|
+
inset-block-start: var(--space-s);
|
|
4339
|
+
inset-inline-start: var(--space-s);
|
|
4340
|
+
padding-block: var(--space-2xs);
|
|
4341
|
+
padding-inline: var(--space-s);
|
|
4342
|
+
position: fixed;
|
|
4343
|
+
text-decoration: none;
|
|
4344
|
+
transform: translateY(calc(-100% - var(--space-m)));
|
|
4345
|
+
transition: transform var(--duration-fast) var(--ease-standard);
|
|
4346
|
+
z-index: 999;
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
.sf-skip-link:focus-visible { transform: translateY(0); }
|
|
4350
|
+
|
|
4351
|
+
.sf-focus-ring:focus-visible {
|
|
4352
|
+
outline: 0.125rem solid var(--color-ring);
|
|
4353
|
+
outline-offset: .2rem;
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
.sf-focus-ring-inset:focus-visible {
|
|
4357
|
+
outline: 0.125rem solid var(--color-ring);
|
|
4358
|
+
outline-offset: -0.125rem;
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4361
|
+
.sf-touch-target {
|
|
4362
|
+
min-block-size: 2.75rem;
|
|
4363
|
+
min-inline-size: 2.75rem;
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
.sf-list-reset {
|
|
4367
|
+
list-style: none;
|
|
4368
|
+
padding-inline-start: 0;
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
.sf-list-disc {
|
|
4372
|
+
list-style: disc;
|
|
4373
|
+
padding-inline-start: var(--space-m);
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
.sf-list-decimal {
|
|
4377
|
+
list-style: decimal;
|
|
4378
|
+
padding-inline-start: var(--space-m);
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
:where(.sf-link, .sf-link-subtle) {
|
|
4382
|
+
text-decoration: underline;
|
|
4383
|
+
text-decoration-color: color-mix(in oklch, currentColor 45%, transparent);
|
|
4384
|
+
text-decoration-skip-ink: auto;
|
|
4385
|
+
text-decoration-thickness: .08em;
|
|
4386
|
+
text-underline-offset: .18em;
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
.sf-link { color: var(--color-primary); }
|
|
4390
|
+
|
|
4391
|
+
.sf-link:hover,
|
|
4392
|
+
.sf-link-subtle:hover {
|
|
4393
|
+
text-decoration-color: currentColor;
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
.sf-link-plain {
|
|
4397
|
+
color: inherit;
|
|
4398
|
+
text-decoration: none;
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
.sf-push-block-end { margin-block-start: auto; }
|
|
4402
|
+
.sf-push-block-start { margin-block-end: auto; }
|
|
4403
|
+
.sf-push-inline-end { margin-inline-start: auto; }
|
|
4404
|
+
.sf-push-inline-start { margin-inline-end: auto; }
|
|
4405
|
+
|
|
4406
|
+
.sf-full-bleed {
|
|
4407
|
+
inline-size: 100vw;
|
|
4408
|
+
margin-inline-start: 50%;
|
|
4409
|
+
transform: translateX(-50%);
|
|
4410
|
+
}
|
|
4411
|
+
}`
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
function buildUtilitiesCss(tokens) {
|
|
4415
|
+
const unsupported = []
|
|
4416
|
+
const rules = []
|
|
4417
|
+
const bases = new Set(tokens.map((token) => splitVariants(token).base))
|
|
4418
|
+
|
|
4419
|
+
for (const token of tokens) {
|
|
4420
|
+
const rule = cssRuleFor(token)
|
|
4421
|
+
if (rule === null) unsupported.push(token)
|
|
4422
|
+
else if (rule) rules.push(rule)
|
|
4423
|
+
}
|
|
4424
|
+
|
|
4425
|
+
if (unsupported.length && !quiet && command !== 'lint') {
|
|
4426
|
+
const report = unsupported.slice(0, 160).join('\n ')
|
|
4427
|
+
console.warn(`Unsupported class tokens: ${unsupported.length}\n ${report}`)
|
|
4428
|
+
}
|
|
4429
|
+
|
|
4430
|
+
return {
|
|
4431
|
+
css: `@layer utilities {
|
|
4432
|
+
${rules.map((rule) => ` ${rule}`).join('\n')}
|
|
4433
|
+
${buildUsedKeyframesCss(bases)}
|
|
4434
|
+
}`,
|
|
4435
|
+
unsupported,
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
function buildUsedKeyframesCss(bases) {
|
|
4440
|
+
const keyframes = []
|
|
4441
|
+
|
|
4442
|
+
if (bases.has('animate-pulse')) keyframes.push('@keyframes sf-pulse { 50% { opacity: .5; } }')
|
|
4443
|
+
if (bases.has('animate-spin')) keyframes.push('@keyframes sf-spin { to { transform: rotate(360deg); } }')
|
|
4444
|
+
if (bases.has('animate-in')) keyframes.push('@keyframes sf-enter { from { opacity: 0; transform: translate3d(0, .5rem, 0) scale(.98); } to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } }')
|
|
4445
|
+
if (bases.has('animate-out')) keyframes.push('@keyframes sf-exit { from { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } to { opacity: 0; transform: translate3d(0, .25rem, 0) scale(.98); } }')
|
|
4446
|
+
|
|
4447
|
+
return keyframes.length ? `\n${keyframes.map((rule) => ` ${rule}`).join('\n')}` : ''
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
function knownPublicClasses() {
|
|
4451
|
+
const classes = Object.values(getTokenSummary().starterClasses).flat()
|
|
4452
|
+
return [...new Set([...classes, ...knownShippedClasses(), ...utilitySuggestionClasses(), 'flex', 'grid', 'hidden', 'block', 'inline-flex', 'relative', 'absolute', 'sticky', 'sr-only', 'not-sr-only'])]
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
function knownShippedClasses() {
|
|
4456
|
+
const cssFile = resolve(packageRoot, 'styles.css')
|
|
4457
|
+
if (!existsSync(cssFile)) return []
|
|
4458
|
+
const css = readFileSync(cssFile, 'utf8')
|
|
4459
|
+
const classes = new Set()
|
|
4460
|
+
for (const match of css.matchAll(/\.(-?[_a-zA-Z]+[_a-zA-Z0-9-]*(?:--?[_a-zA-Z0-9-]+)?(?:__[_a-zA-Z0-9-]+)?)/g)) {
|
|
4461
|
+
classes.add(match[1])
|
|
4462
|
+
}
|
|
4463
|
+
return [...classes]
|
|
4464
|
+
}
|
|
4465
|
+
|
|
4466
|
+
function utilitySuggestionClasses() {
|
|
4467
|
+
const colours = Object.keys(semanticColours)
|
|
4468
|
+
const spacing = ['0', '1', '2', '3', '4', '6', '8', '10', '12']
|
|
4469
|
+
return [
|
|
4470
|
+
...colours.flatMap((name) => [`text-${name}`, `bg-${name}`, `border-${name}`, `ring-${name}`]),
|
|
4471
|
+
...spacing.flatMap((name) => [`p-${name}`, `px-${name}`, `py-${name}`, `m-${name}`, `gap-${name}`]),
|
|
4472
|
+
'text-sm',
|
|
4473
|
+
'text-base',
|
|
4474
|
+
'text-lg',
|
|
4475
|
+
'font-sans',
|
|
4476
|
+
'font-display',
|
|
4477
|
+
'rounded',
|
|
4478
|
+
'rounded-lg',
|
|
4479
|
+
'shadow-md',
|
|
4480
|
+
]
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
function nearestClass(token) {
|
|
4484
|
+
const { base } = splitVariants(token)
|
|
4485
|
+
const candidates = knownPublicClasses()
|
|
4486
|
+
const scored = candidates
|
|
4487
|
+
.map((candidate) => ({ candidate, score: levenshtein(base, candidate) }))
|
|
4488
|
+
.sort((a, b) => a.score - b.score)
|
|
4489
|
+
return scored[0]?.score <= Math.max(4, Math.ceil(base.length / 3)) ? scored[0].candidate : null
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4492
|
+
function lintSourceFiles() {
|
|
4493
|
+
const files = new Set()
|
|
4494
|
+
for (const dir of sourceDirs) {
|
|
4495
|
+
const fullDir = resolve(repoRoot, dir)
|
|
4496
|
+
if (existsSync(fullDir) && statSync(fullDir).isDirectory()) {
|
|
4497
|
+
for (const file of listProjectFiles(fullDir)) files.add(file)
|
|
4498
|
+
} else if (existsSync(fullDir)) {
|
|
4499
|
+
files.add(fullDir)
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
return [...files]
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
function collectCompositionIssues() {
|
|
4506
|
+
const issues = []
|
|
4507
|
+
for (const file of lintSourceFiles()) {
|
|
4508
|
+
const source = readFileSync(file, 'utf8')
|
|
4509
|
+
const rel = relative(repoRoot, file)
|
|
4510
|
+
const isCss = file.endsWith('.css')
|
|
4511
|
+
const isPackageSelf = repoRoot === packageRoot
|
|
4512
|
+
|
|
4513
|
+
if (!isCss && /\bpopover(?:=|\s|>)/i.test(source) && !/(popoverTargetAction|popovertargetaction)=["']hide["']|command=["']hide-popover["']/i.test(source)) {
|
|
4514
|
+
issues.push(lintIssue('popover-missing-close', 'warn', rel, source, source.search(/\bpopover/i), 'Popover markup has no native close or hide invoker.', 'Add a close button or hide-on-navigate button. See: synced-flow pattern mobile-nav-drawer --markup'))
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
if (!isCss && source.includes('sf-nav--mobile') && !/<button\b[^>]*(popoverTarget|popovertarget|commandFor|commandfor)=/i.test(source)) {
|
|
4518
|
+
issues.push(lintIssue('mobile-nav-incomplete', 'warn', rel, source, source.indexOf('sf-nav--mobile'), 'sf-nav--mobile has no menu trigger.', 'Add a button with popoverTarget pointing to an sf-drawer popover. See: synced-flow pattern mobile-nav-drawer --markup'))
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
for (const match of isCss ? [] : source.matchAll(/`[^`]*(?:sf-|text-|bg-|border-|p[trblxy]?-|m[trblxy]?-|gap-)\$\{[\s\S]*?`/g)) {
|
|
4522
|
+
issues.push(lintIssue('dynamic-class-fragment', 'warn', rel, source, match.index ?? 0, 'Class names are built from dynamic fragments.', 'Use complete class strings in source and safelist unavoidable dynamic classes.'))
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
for (const match of isCss ? [] : source.matchAll(/<a\b[^>]*(?:popoverTarget|popovertarget)=/gi)) {
|
|
4526
|
+
issues.push(lintIssue('invalid-popover-on-anchor', 'warn', rel, source, match.index ?? 0, 'popoverTarget is attached to an anchor.', 'Use a button invoker. For hash navigation, use a button with formAction plus popoverTargetAction="hide".'))
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
const customTokenOverrideIndex = isCss ? findCustomTokenOverrideIndex(source) : -1
|
|
4530
|
+
if (isCss && !isPackageSelf && customTokenOverrideIndex >= 0 && !/synced-flow\.generated\.css$/.test(rel)) {
|
|
4531
|
+
issues.push(lintIssue('theme-override-in-css', 'warn', rel, source, customTokenOverrideIndex, 'Project CSS defines custom --sf-* tokens.', 'Move repeated brand decisions to synced-flow.config.mjs theme tokens.'))
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
return issues
|
|
4535
|
+
}
|
|
4536
|
+
|
|
4537
|
+
function lintIssue(rule, severity, file, source, index, message, fix) {
|
|
4538
|
+
return {
|
|
4539
|
+
rule,
|
|
4540
|
+
severity,
|
|
4541
|
+
file,
|
|
4542
|
+
line: lineNumberAt(source, Math.max(0, index)),
|
|
4543
|
+
message,
|
|
4544
|
+
fix,
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4547
|
+
|
|
4548
|
+
function lineNumberAt(source, index) {
|
|
4549
|
+
return source.slice(0, index).split('\n').length
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
function levenshtein(a, b) {
|
|
4553
|
+
const previous = Array.from({ length: b.length + 1 }, (_, index) => index)
|
|
4554
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
4555
|
+
const current = [i + 1]
|
|
4556
|
+
for (let j = 0; j < b.length; j += 1) {
|
|
4557
|
+
current[j + 1] = Math.min(
|
|
4558
|
+
current[j] + 1,
|
|
4559
|
+
previous[j + 1] + 1,
|
|
4560
|
+
previous[j] + (a[i] === b[j] ? 0 : 1)
|
|
4561
|
+
)
|
|
4562
|
+
}
|
|
4563
|
+
previous.splice(0, previous.length, ...current)
|
|
4564
|
+
}
|
|
4565
|
+
return previous[b.length]
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4568
|
+
const tokens = collectClassTokens()
|
|
4569
|
+
const utilities = buildUtilitiesCss(tokens)
|
|
4570
|
+
const coreCss = includeCore ? [buildBaseCss(), includeDefaults ? buildDefaultsCss() : '', buildLayoutCss(), buildComponentCss(), buildStaticUtilitiesCss()] : []
|
|
4571
|
+
const themeCss = buildThemeCss(config.theme)
|
|
4572
|
+
const css = [
|
|
4573
|
+
'/* Generated by @syncedco/flow. Edit synced-flow.config.mjs or run synced-flow build to refresh. */',
|
|
4574
|
+
'@layer reset, tokens, base, app, layout, components, utilities, overrides;',
|
|
4575
|
+
buildTokensCss(),
|
|
4576
|
+
themeCss,
|
|
4577
|
+
...coreCss,
|
|
4578
|
+
utilities.css,
|
|
4579
|
+
].filter(Boolean).join('\n\n') + '\n'
|
|
4580
|
+
|
|
4581
|
+
if (command === 'lint') {
|
|
4582
|
+
const json = args.includes('--json')
|
|
4583
|
+
const fix = args.includes('--fix')
|
|
4584
|
+
const known = new Set(knownPublicClasses())
|
|
4585
|
+
const unknownStatic = tokens.filter((token) => {
|
|
4586
|
+
const { base } = splitVariants(token)
|
|
4587
|
+
return base.startsWith('sf-') && !known.has(base)
|
|
4588
|
+
})
|
|
4589
|
+
const lintFailures = [...new Set([...utilities.unsupported, ...unknownStatic])]
|
|
4590
|
+
const issues = [
|
|
4591
|
+
...lintFailures.map((token) => ({
|
|
4592
|
+
rule: 'unknown-generated-utility',
|
|
4593
|
+
severity: 'error',
|
|
4594
|
+
file: null,
|
|
4595
|
+
line: null,
|
|
4596
|
+
message: `Unsupported class token "${token}".`,
|
|
4597
|
+
fix: nearestClass(token) ? `Use "${nearestClass(token)}" or run synced-flow catalog --json for supported classes.` : 'Use a supported Synced Flow class, a generated utility, or add a safelist entry for unavoidable dynamic output.',
|
|
4598
|
+
token,
|
|
4599
|
+
})),
|
|
4600
|
+
...collectCompositionIssues(),
|
|
4601
|
+
]
|
|
4602
|
+
const hasErrors = issues.some((issue) => issue.severity === 'error')
|
|
4603
|
+
|
|
4604
|
+
if (json) {
|
|
4605
|
+
console.log(JSON.stringify({ ok: issues.length === 0, issues }, null, 2))
|
|
4606
|
+
process.exit(hasErrors ? 1 : 0)
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
if (!issues.length) {
|
|
4610
|
+
console.log('pass no unsupported class tokens found.')
|
|
4611
|
+
} else {
|
|
4612
|
+
if (lintFailures.length) console.error(`Unsupported class tokens: ${lintFailures.length}`)
|
|
4613
|
+
for (const token of lintFailures.slice(0, 160)) {
|
|
4614
|
+
const suggestion = nearestClass(token)
|
|
4615
|
+
console.error(` - ${token}${suggestion ? ` (did you mean ${suggestion}?)` : ''}`)
|
|
4616
|
+
}
|
|
4617
|
+
const warnings = issues.filter((issue) => issue.severity !== 'error')
|
|
4618
|
+
if (warnings.length) {
|
|
4619
|
+
console.error(`Composition warnings: ${warnings.length}`)
|
|
4620
|
+
for (const issue of warnings.slice(0, 160)) console.error(` - ${issue.file}:${issue.line} ${issue.rule}: ${issue.message} ${issue.fix}`)
|
|
4621
|
+
}
|
|
4622
|
+
if (fix) console.error('No automatic fixes were applied; current composition rules provide guided fixes only.')
|
|
4623
|
+
}
|
|
4624
|
+
process.exit(hasErrors ? 1 : 0)
|
|
4625
|
+
}
|
|
4626
|
+
|
|
4627
|
+
if (checkOnly) {
|
|
4628
|
+
const current = existsSync(outFile) ? readFileSync(outFile, 'utf8') : ''
|
|
4629
|
+
if (current !== css) {
|
|
4630
|
+
console.error(`${relative(repoRoot, outFile)} is out of date. Run synced-flow build.`)
|
|
4631
|
+
process.exit(1)
|
|
4632
|
+
}
|
|
4633
|
+
} else {
|
|
4634
|
+
writeGeneratedCss(outFile, css)
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
if (utilities.unsupported.length && failOnUnsupported) {
|
|
4638
|
+
process.exitCode = 1
|
|
4639
|
+
}
|