purgetss 7.5.2 → 7.6.1

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.
Files changed (50) hide show
  1. package/README.md +93 -11
  2. package/bin/purgetss +140 -1
  3. package/dist/purgetss.ui.js +65 -26
  4. package/dist/utilities.tss +21 -4
  5. package/experimental/completions2.js +1 -1
  6. package/lib/completions/titanium/completions-v3.json +62 -1
  7. package/lib/templates/purgetss.config.js.cjs +15 -1
  8. package/lib/templates/purgetss.ui.js.cjs +64 -25
  9. package/package.json +3 -1
  10. package/src/cli/commands/brand.js +69 -0
  11. package/src/cli/commands/create.js +11 -7
  12. package/src/cli/commands/fonts.js +9 -9
  13. package/src/cli/commands/icon-library.js +18 -16
  14. package/src/cli/commands/images.js +116 -0
  15. package/src/cli/commands/init.js +4 -0
  16. package/src/cli/commands/module.js +4 -2
  17. package/src/cli/commands/purge.js +77 -101
  18. package/src/cli/commands/semantic.js +180 -0
  19. package/src/cli/commands/shades.js +332 -13
  20. package/src/cli/utils/project-detection.js +4 -2
  21. package/src/core/analyzers/class-extractor.js +110 -3
  22. package/src/core/branding/brand-config.js +111 -0
  23. package/src/core/branding/branding-logger.js +40 -0
  24. package/src/core/branding/cleanup-legacy.js +220 -0
  25. package/src/core/branding/ensure-brand-section.js +80 -0
  26. package/src/core/branding/gen-android-adaptive.js +116 -0
  27. package/src/core/branding/gen-android-legacy.js +63 -0
  28. package/src/core/branding/gen-ic-launcher-xml.js +29 -0
  29. package/src/core/branding/gen-ios-dark.js +70 -0
  30. package/src/core/branding/gen-ios-tinted.js +55 -0
  31. package/src/core/branding/gen-ios.js +69 -0
  32. package/src/core/branding/gen-marketplace.js +71 -0
  33. package/src/core/branding/gen-notification.js +76 -0
  34. package/src/core/branding/gen-splash.js +64 -0
  35. package/src/core/branding/index.js +336 -0
  36. package/src/core/branding/post-gen-notes.js +145 -0
  37. package/src/core/branding/prepare-master.js +108 -0
  38. package/src/core/branding/tiapp-reader.js +110 -0
  39. package/src/core/builders/tailwind-helpers.js +1 -1
  40. package/src/core/images/ensure-images-section.js +57 -0
  41. package/src/core/images/gen-scales.js +181 -0
  42. package/src/core/images/index.js +171 -0
  43. package/src/shared/config-manager.js +46 -0
  44. package/src/shared/config-writer.js +84 -0
  45. package/src/shared/constants.js +3 -0
  46. package/src/shared/helpers/typography.js +38 -3
  47. package/src/shared/logger.js +69 -4
  48. package/src/shared/prompt.js +64 -0
  49. package/src/shared/svg-utils.js +80 -0
  50. package/src/shared/utils.js +8 -4
@@ -0,0 +1,336 @@
1
+ /**
2
+ * PurgeTSS - Branding pipeline orchestrator
3
+ *
4
+ * Composes the branding pipeline for Titanium projects.
5
+ *
6
+ * Every invocation (kitchen-sink) generates:
7
+ *
8
+ * iOS & marketplace:
9
+ * - DefaultIcon.png (alpha preserved)
10
+ * - DefaultIcon-ios.png (flattened on bg-color)
11
+ * - DefaultIcon-Dark.png (iOS 18+, transparent by default)
12
+ * - DefaultIcon-Tinted.png (iOS 18+, grayscale on black)
13
+ * - iTunesConnect.png (1024²)
14
+ * - MarketplaceArtwork.png (512²)
15
+ *
16
+ * Android:
17
+ * - ic_launcher_foreground.png × 5 densities
18
+ * - ic_launcher_background.png × 5 densities
19
+ * - ic_launcher_monochrome.png × 5 densities (themed icons / dark mode)
20
+ * - ic_launcher.png × 5 densities (legacy pre-adaptive)
21
+ * - mipmap-anydpi-v26/ic_launcher.xml
22
+ *
23
+ * Optional (opt-in):
24
+ * - ic_stat_notify.png × 5 (--notification)
25
+ * - splash_icon.png × 5 (--splash)
26
+ *
27
+ * Opt-out:
28
+ * - DefaultIcon-Dark.png (--no-dark)
29
+ * - DefaultIcon-Tinted.png (--no-tinted)
30
+ *
31
+ * @fileoverview Titanium branding pipeline orchestrator
32
+ * @author César Estrada
33
+ */
34
+
35
+ import fs from 'fs'
36
+ import os from 'os'
37
+ import path from 'path'
38
+ import { logger } from './branding-logger.js'
39
+ import { logger as mainLogger } from '../../shared/logger.js'
40
+ import { confirmWithAlways } from '../../shared/prompt.js'
41
+ import { setConfigProperty } from '../../shared/config-writer.js'
42
+ import { prepareMaster } from './prepare-master.js'
43
+ import { genIos } from './gen-ios.js'
44
+ import { genIosDark } from './gen-ios-dark.js'
45
+ import { genIosTinted } from './gen-ios-tinted.js'
46
+ import { genAndroidAdaptive } from './gen-android-adaptive.js'
47
+ import { genAndroidLegacy } from './gen-android-legacy.js'
48
+ import { genMarketplace } from './gen-marketplace.js'
49
+ import { genNotification } from './gen-notification.js'
50
+ import { genSplash } from './gen-splash.js'
51
+ import { genIcLauncherXml } from './gen-ic-launcher-xml.js'
52
+ import { detectProjectType } from './tiapp-reader.js'
53
+ import { cleanupLegacy } from './cleanup-legacy.js'
54
+ import { printPostGenNotes } from './post-gen-notes.js'
55
+
56
+ export async function runBranding(opts) {
57
+ const {
58
+ logo,
59
+ monochromeLogo = null,
60
+ darkLogo = null,
61
+ darkBgColor = null,
62
+ withDark = true,
63
+ withTinted = true,
64
+ tintedLogo = null,
65
+ bgColor = '#FFFFFF',
66
+ bgColorExplicit = false,
67
+ padding = 15,
68
+ iosPadding = 4,
69
+ notification = false,
70
+ splash = false,
71
+ cleanupLegacy: runCleanup = false,
72
+ aggressive = false,
73
+ projectRoot = process.cwd(),
74
+ output,
75
+ dryRun = false,
76
+ inPlace = false,
77
+ notes = false,
78
+ yes = false,
79
+ confirmOverwrites = true
80
+ } = opts
81
+
82
+ validateOptions({ logo, bgColor, darkBgColor, padding, iosPadding, cleanupLegacy: runCleanup })
83
+
84
+ const projectType = detectProjectType(projectRoot)
85
+ const isInPlace = inPlace && !output
86
+ const stagingRoot = output || (isInPlace ? projectRoot : path.join(projectRoot, '.ti-branding'))
87
+
88
+ console.log()
89
+ mainLogger.info('Generating branding assets...')
90
+ console.log()
91
+ logger.property('Project: ', `${projectRoot} (${projectType})`)
92
+ if (logo) {
93
+ logger.property('Logo: ', logo)
94
+ logger.property('Background: ', bgColor)
95
+ logger.property('Padding: ', `Android ${padding}% / iOS ${iosPadding}% per side`)
96
+ console.log()
97
+ logger.property(isInPlace ? 'Writing IN PLACE to: ' : 'Staging: ', isInPlace ? projectRoot : stagingRoot)
98
+ }
99
+ if (isInPlace && !dryRun && confirmOverwrites) {
100
+ logger.warning(`⚠ In-place mode will OVERWRITE files in ${projectRoot}.`)
101
+ logger.warning(` Commit first if you want a rollback.`)
102
+ const choice = await confirmWithAlways('Continue? [y/N/a]', { yes })
103
+ if (choice === 'no') {
104
+ logger.info('Aborted.')
105
+ // eslint-disable-next-line n/no-process-exit
106
+ process.exit(0)
107
+ }
108
+ if (choice === 'always') {
109
+ const saved = setConfigProperty('brand', 'confirmOverwrites', false)
110
+ if (saved) {
111
+ logger.success('Saved brand.confirmOverwrites = false to purgetss/config.cjs — you won\'t be asked again.')
112
+ } else {
113
+ logger.warning('Could not persist preference (config.cjs missing or unreadable). Proceeding anyway.')
114
+ }
115
+ }
116
+ }
117
+ if (dryRun) logger.warning('DRY RUN — no files will be written')
118
+
119
+ const generated = []
120
+
121
+ // Cleanup-only mode
122
+ if (!logo && runCleanup) {
123
+ logger.info('Cleanup-only mode')
124
+ await cleanupLegacy({ projectRoot, projectType, aggressive, dryRun })
125
+ return { stagingRoot, generated }
126
+ }
127
+
128
+ if (!logo) {
129
+ throw new Error('Logo image is required (unless running --cleanup-legacy alone).')
130
+ }
131
+ if (!fs.existsSync(logo)) {
132
+ throw new Error(`Logo image not found: ${logo}`)
133
+ }
134
+
135
+ if (projectType === 'unknown') {
136
+ logger.warning(`Could not detect project layout. Expected 'app/' (Alloy) or 'Resources/' (Classic).`)
137
+ logger.warning(`Assets will be staged under ${stagingRoot}/standalone/ — copy manually.`)
138
+ }
139
+
140
+ const androidResStaging = getStagingAndroidResRoot(stagingRoot, projectType)
141
+
142
+ if (dryRun) {
143
+ const lines = [
144
+ `${stagingRoot}/DefaultIcon.png + DefaultIcon-ios.png`
145
+ ]
146
+ if (withDark) {
147
+ const darkSrc = darkLogo
148
+ ? `from ${darkLogo}`
149
+ : (darkBgColor ? `opaque bg ${darkBgColor}` : 'transparent per Apple HIG')
150
+ lines.push(`${stagingRoot}/DefaultIcon-Dark.png (${darkSrc})`)
151
+ }
152
+ if (withTinted) {
153
+ const tintedSrc = tintedLogo ? `from ${tintedLogo}` : 'grayscale of logo, flattened on black'
154
+ lines.push(`${stagingRoot}/DefaultIcon-Tinted.png (${tintedSrc})`)
155
+ }
156
+ lines.push(`${stagingRoot}/iTunesConnect.png + MarketplaceArtwork.png`)
157
+ lines.push(`${androidResStaging}/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher_{foreground,background,monochrome}.png`)
158
+ lines.push(`${androidResStaging}/mipmap-{...}/ic_launcher.png (legacy)`)
159
+ lines.push(`${androidResStaging}/mipmap-anydpi-v26/ic_launcher.xml`)
160
+ if (notification) lines.push(`${androidResStaging}/drawable-*/ic_stat_notify.png × 5`)
161
+ if (splash) lines.push(`${androidResStaging}/drawable-*/splash_icon.png × 5`)
162
+ mainLogger.block('[dry-run] Would generate:', ...lines)
163
+ if (runCleanup) {
164
+ await cleanupLegacy({ projectRoot, projectType, aggressive, dryRun })
165
+ }
166
+ return { stagingRoot, generated }
167
+ }
168
+
169
+ // Route temp logos through the OS temp dir in --in-place mode so the
170
+ // project tree (and VSCode's file explorer) stays clean. Using a unique
171
+ // subdir per run avoids clashes between parallel invocations.
172
+ const tempDir = isInPlace
173
+ ? path.join(os.tmpdir(), `pt-branding-${process.pid}-${Date.now()}`)
174
+ : stagingRoot
175
+ const weCreatedTempDir = isInPlace && !fs.existsSync(tempDir)
176
+ if (weCreatedTempDir) fs.mkdirSync(tempDir, { recursive: true })
177
+
178
+ // ---- Section: Logos ---------------------------------------------------
179
+ logger.section('Logos')
180
+ logger.bullet('Dual logos (square + tight)')
181
+ const logoBase = path.join(tempDir, '_logo')
182
+ const { tight } = await prepareMaster(logo, logoBase)
183
+
184
+ let monoTight = null
185
+ if (monochromeLogo) {
186
+ if (!fs.existsSync(monochromeLogo)) {
187
+ throw new Error(`Monochrome logo not found: ${monochromeLogo}`)
188
+ }
189
+ logger.bullet(`Monochrome logo: ${monochromeLogo}`)
190
+ const monoBase = path.join(tempDir, '_logo_mono')
191
+ const monoResult = await prepareMaster(monochromeLogo, monoBase)
192
+ monoTight = monoResult.tight
193
+ }
194
+
195
+ // ---- Section: iOS & marketplace ----------------------------------------
196
+ logger.section('iOS & marketplace')
197
+ logger.bullet(`DefaultIcon.png (Android-safe padding ${padding}%) + DefaultIcon-ios.png (iOS padding ${iosPadding}%)`)
198
+ const ios = await genIos(tight, bgColor, padding, iosPadding, stagingRoot)
199
+ generated.push(ios.defaultIcon, ios.defaultIconIos)
200
+
201
+ if (withDark) {
202
+ let darkSource = tight
203
+ if (darkLogo) {
204
+ if (!fs.existsSync(darkLogo)) throw new Error(`Dark logo not found: ${darkLogo}`)
205
+ const darkBase = path.join(tempDir, '_logo_dark')
206
+ const darkResult = await prepareMaster(darkLogo, darkBase)
207
+ darkSource = darkResult.tight
208
+ }
209
+ const darkSrcLabel = darkLogo ? 'from --dark-logo, ' : ''
210
+ const darkBgLabel = darkBgColor ? `opaque bg ${darkBgColor}` : 'transparent per Apple HIG'
211
+ logger.bullet(`DefaultIcon-Dark.png (${darkSrcLabel}${darkBgLabel})`)
212
+ const darkPath = await genIosDark(darkSource, darkBgColor, iosPadding, stagingRoot)
213
+ generated.push(darkPath)
214
+ }
215
+
216
+ if (withTinted) {
217
+ let tintedSource = tight
218
+ if (tintedLogo) {
219
+ if (!fs.existsSync(tintedLogo)) throw new Error(`Tinted logo not found: ${tintedLogo}`)
220
+ const tintedBase = path.join(tempDir, '_logo_tinted')
221
+ const tintedResult = await prepareMaster(tintedLogo, tintedBase)
222
+ tintedSource = tintedResult.tight
223
+ }
224
+ const tintedSrcLabel = tintedLogo ? 'from --tinted-logo' : 'grayscale of logo'
225
+ logger.bullet(`DefaultIcon-Tinted.png (${tintedSrcLabel}, flattened on black)`)
226
+ const tintedPath = await genIosTinted(tintedSource, iosPadding, stagingRoot)
227
+ generated.push(tintedPath)
228
+ }
229
+
230
+ const alphaMode = bgColorExplicit ? `flattened on ${bgColor}` : 'alpha preserved'
231
+ logger.bullet(`iTunesConnect.png + MarketplaceArtwork.png (${alphaMode})`)
232
+ const mkt = await genMarketplace(tight, iosPadding, stagingRoot, {
233
+ flatten: bgColorExplicit,
234
+ bgColor
235
+ })
236
+ generated.push(mkt.itunesConnect, mkt.marketplaceArtwork)
237
+
238
+ // ---- Section: Android --------------------------------------------------
239
+ logger.section('Android')
240
+
241
+ const monoLabel = monoTight ? ', monochrome from --monochrome-logo' : ''
242
+ logger.bullet(`Adaptive icons (foreground + background + monochrome${monoLabel}) × 5`)
243
+ const adaptiveFiles = await genAndroidAdaptive(tight, bgColor, padding, androidResStaging, { monoTight })
244
+ generated.push(...adaptiveFiles)
245
+
246
+ logger.bullet('Legacy ic_launcher.png × 5')
247
+ const legacyFiles = await genAndroidLegacy(tight, bgColor, padding, androidResStaging)
248
+ generated.push(...legacyFiles)
249
+
250
+ const xmlPath = genIcLauncherXml(androidResStaging)
251
+ generated.push(xmlPath)
252
+ logger.bullet(`Adaptive icon XML: ${xmlPath}`)
253
+
254
+ if (notification) {
255
+ const monoLabelNotif = monoTight ? ' from --monochrome-logo' : ' whitened from logo'
256
+ logger.bullet(`Notification icons (white+alpha, edge-to-edge${monoLabelNotif}) × 5`)
257
+ const notifFiles = await genNotification(monoTight || tight, androidResStaging)
258
+ generated.push(...notifFiles)
259
+ }
260
+
261
+ if (splash) {
262
+ logger.bullet('Splash icons × 5')
263
+ const splashFiles = await genSplash(tight, androidResStaging)
264
+ generated.push(...splashFiles)
265
+ }
266
+
267
+ if (runCleanup) {
268
+ logger.info('Cleanup legacy artifacts')
269
+ await cleanupLegacy({ projectRoot, projectType, aggressive, dryRun })
270
+ }
271
+
272
+ // Clean up temp _logo_* files in --in-place mode
273
+ if (isInPlace) {
274
+ if (weCreatedTempDir) {
275
+ fs.rmSync(tempDir, { recursive: true, force: true })
276
+ } else {
277
+ const tmpFiles = [
278
+ path.join(tempDir, '_logo_square.png'),
279
+ path.join(tempDir, '_logo_tight.png'),
280
+ path.join(tempDir, '_logo_mono_square.png'),
281
+ path.join(tempDir, '_logo_mono_tight.png'),
282
+ path.join(tempDir, '_logo_dark_square.png'),
283
+ path.join(tempDir, '_logo_dark_tight.png'),
284
+ path.join(tempDir, '_logo_tinted_square.png'),
285
+ path.join(tempDir, '_logo_tinted_tight.png')
286
+ ]
287
+ for (const tmp of tmpFiles) {
288
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
289
+ }
290
+ }
291
+ logger.info('')
292
+ logger.success(`All assets written IN PLACE at: ${projectRoot}`)
293
+ } else {
294
+ logger.info('')
295
+ logger.success(`All assets staged at: ${stagingRoot}`)
296
+ }
297
+
298
+ printPostGenNotes({
299
+ projectType,
300
+ projectRoot,
301
+ stagingRoot,
302
+ bgColor,
303
+ padding,
304
+ iosPadding,
305
+ withSplash: splash,
306
+ withNotification: notification,
307
+ inPlace: isInPlace,
308
+ fullNotes: notes
309
+ })
310
+
311
+ return { stagingRoot, generated }
312
+ }
313
+
314
+ function getStagingAndroidResRoot(stagingRoot, projectType) {
315
+ if (projectType === 'alloy') return path.join(stagingRoot, 'app', 'platform', 'android', 'res')
316
+ if (projectType === 'classic') return path.join(stagingRoot, 'platform', 'android', 'res')
317
+ return path.join(stagingRoot, 'standalone', 'platform', 'android', 'res')
318
+ }
319
+
320
+ function validateOptions({ logo, bgColor, darkBgColor, padding, iosPadding, cleanupLegacy }) {
321
+ if (!logo && !cleanupLegacy) {
322
+ throw new Error('Logo image path is required (unless using --cleanup-legacy alone).')
323
+ }
324
+ if (!/^#[0-9A-Fa-f]{6}$/.test(bgColor)) {
325
+ throw new Error(`--bg-color must be a 6-digit hex like #0B1326 (got: ${bgColor}).`)
326
+ }
327
+ if (darkBgColor && !/^#[0-9A-Fa-f]{6}$/.test(darkBgColor)) {
328
+ throw new Error(`--dark-bg-color must be a 6-digit hex like #1C1C1E (got: ${darkBgColor}).`)
329
+ }
330
+ if (padding < 0 || padding > 40) {
331
+ throw new Error(`--padding must be between 0 and 40 (got: ${padding}).`)
332
+ }
333
+ if (iosPadding < 0 || iosPadding > 40) {
334
+ throw new Error(`--ios-padding must be between 0 and 40 (got: ${iosPadding}).`)
335
+ }
336
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * PurgeTSS - post-gen-notes
3
+ *
4
+ * Prints guidance after a successful branding run. Two modes:
5
+ * - default (compact): one-line per category + "Next steps" block
6
+ * - `--notes` (full): adds brand color reminder, padding tips, and all
7
+ * tiapp.xml snippets (iOS launch, Android launcher,
8
+ * Android 12+ splash theme, FCM notification tint)
9
+ *
10
+ * @fileoverview Post-generation guidance output
11
+ * @author César Estrada
12
+ */
13
+
14
+ import chalk from 'chalk'
15
+ import { logger } from './branding-logger.js'
16
+
17
+ export function printPostGenNotes(opts) {
18
+ if (opts.fullNotes) {
19
+ printFullNotes(opts)
20
+ } else {
21
+ printCompactSummary(opts)
22
+ }
23
+ }
24
+
25
+ function printCompactSummary(opts) {
26
+ const { projectType, projectRoot, stagingRoot, bgColor, padding, iosPadding, inPlace } = opts
27
+
28
+ logger.section('Summary')
29
+ logger.bullet(`Background: ${chalk.cyan(bgColor)}`)
30
+ logger.bullet(`Padding: Android ${chalk.cyan(padding + '%')} / iOS ${chalk.cyan(iosPadding + '%')}`)
31
+ logger.bullet(`${inPlace ? 'Written in place to' : 'Staged at'}: ${chalk.cyan(inPlace ? projectRoot : stagingRoot)}`)
32
+
33
+ logger.section('Next steps')
34
+ if (inPlace) {
35
+ logger.bullet(`Preview the new icons in ${chalk.yellow('Preview.app')}.`)
36
+ logger.bullet(`If something looks wrong: ${chalk.gray('git checkout -- .')}`)
37
+ logger.bullet(`Rebuild: ${chalk.gray('ti clean && ti build -p android -T emulator')}`)
38
+ } else if (projectType === 'alloy') {
39
+ logger.bullet(`Preview in ${chalk.yellow('Preview.app')}, then copy to project:`)
40
+ console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork}.png ${projectRoot}/`))
41
+ console.log(chalk.gray(` cp -R ${stagingRoot}/app/platform/android/res/. ${projectRoot}/app/platform/android/res/`))
42
+ logger.bullet(`Cleanup staging: ${chalk.gray('rm -rf ' + stagingRoot)}`)
43
+ } else if (projectType === 'classic') {
44
+ logger.bullet(`Preview in ${chalk.yellow('Preview.app')}, then copy to project:`)
45
+ console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork}.png ${projectRoot}/`))
46
+ console.log(chalk.gray(` cp -R ${stagingRoot}/platform/android/res/. ${projectRoot}/platform/android/res/`))
47
+ logger.bullet(`Cleanup staging: ${chalk.gray('rm -rf ' + stagingRoot)}`)
48
+ } else {
49
+ logger.bullet(`Review ${chalk.cyan(stagingRoot + '/')} and copy files to their final paths manually.`)
50
+ }
51
+ console.log()
52
+ console.log(`Pass ${chalk.yellow('--notes')} to print tiapp.xml snippets + padding tuning guide.`)
53
+ console.log()
54
+ }
55
+
56
+ function printFullNotes(opts) {
57
+ const {
58
+ projectType, projectRoot, stagingRoot,
59
+ bgColor, padding, iosPadding, withSplash, withNotification, inPlace
60
+ } = opts
61
+
62
+ const code = (s) => chalk.gray(s)
63
+ const flag = (s) => chalk.yellow(s)
64
+ const num = (n) => chalk.cyan(n)
65
+
66
+ logger.section('Notes on what was generated')
67
+ logger.bullet(`Brand color ${chalk.cyan(bgColor)} was baked into Android adaptive background layer`)
68
+ console.log(' and iOS/marketplace flattened masters (Apple rejects alpha).')
69
+ logger.bullet(`Android padding: ${chalk.cyan(padding + '%')} (logo fills ${100 - 2 * padding}% of each mipmap canvas)`)
70
+ logger.bullet(`iOS padding: ${chalk.cyan(iosPadding + '%')} (logo fills ${100 - 2 * iosPadding}% of DefaultIcon-ios and marketplace art)`)
71
+
72
+ console.log()
73
+ console.log(' If the logo looks cramped: re-run with higher padding')
74
+ console.log(` ${flag('--padding 25-30')} (Android)`)
75
+ console.log(` ${flag('--ios-padding 10-14')} (iOS)`)
76
+ console.log()
77
+ console.log(' If the logo looks too small: re-run with lower padding')
78
+ console.log(` ${flag('--padding 19')} (Android spec floor)`)
79
+ console.log(` ${flag('--ios-padding 2-3')} (matches first-party apps like Mail, Safari)`)
80
+
81
+ logger.section('Configuration reminders')
82
+ console.log(' The tool does NOT auto-edit tiapp.xml. Snippets below are optional —')
83
+ console.log(' paste only what you need, after reviewing.')
84
+ console.log()
85
+ console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('tiapp.xml <application> tag may be self-closing')}`)
86
+ console.log(' If yours looks like:')
87
+ console.log(code(' <application android:icon="@mipmap/ic_launcher" .../>'))
88
+ console.log(' You must expand it BEFORE adding children:')
89
+ console.log(code(' <application android:icon="@mipmap/ic_launcher" ...>'))
90
+ console.log(code(' </application>'))
91
+
92
+ console.log()
93
+ console.log(` ${num('1.')} ${chalk.cyan('iOS launch background')} — under ${flag('<ios>')} in tiapp.xml:`)
94
+ console.log(code(' <ios>'))
95
+ console.log(code(' <enable-launch-screen-storyboard>true</enable-launch-screen-storyboard>'))
96
+ console.log(code(` <default-background-color>${bgColor}</default-background-color>`))
97
+ console.log(code(' </ios>'))
98
+
99
+ console.log()
100
+ console.log(` ${num('2.')} ${chalk.cyan('Android launcher icon')} — under ${flag('<android><manifest><application>')}:`)
101
+ console.log(code(' <application android:icon="@mipmap/ic_launcher"'))
102
+ console.log(code(' android:usesCleartextTraffic="false"/>'))
103
+
104
+ if (withSplash) {
105
+ console.log()
106
+ console.log(` ${num('3.')} ${chalk.cyan('Android 12+ splash screen')} — ${chalk.yellow('OPTIONAL, advanced')}`)
107
+ console.log()
108
+ console.log(' Titanium SDK 13.x shows a system splash automatically using your')
109
+ console.log(' launcher icon. For most apps THE DEFAULT IS ENOUGH — do nothing.')
110
+ }
111
+
112
+ if (withNotification) {
113
+ const colorsDir = projectType === 'classic'
114
+ ? 'platform/android/res/values'
115
+ : 'app/platform/android/res/values'
116
+
117
+ console.log()
118
+ console.log(` ${num('4.')} ${chalk.cyan('FCM notification icon + tint')}`)
119
+ console.log(' Only needed if you use firebase.cloudmessaging for push.')
120
+ console.log()
121
+ console.log(` Create ${flag(colorsDir + '/colors.xml')} (or merge):`)
122
+ console.log(code(' <?xml version="1.0" encoding="utf-8"?>'))
123
+ console.log(code(' <resources>'))
124
+ console.log(code(` <color name="notification_tint">${bgColor}</color>`))
125
+ console.log(code(' </resources>'))
126
+ console.log()
127
+ console.log(' Then under <application> in tiapp.xml:')
128
+ console.log(code(' <meta-data android:name="com.google.firebase.messaging.default_notification_icon"'))
129
+ console.log(code(' android:resource="@drawable/ic_stat_notify"/>'))
130
+ console.log(code(' <meta-data android:name="com.google.firebase.messaging.default_notification_color"'))
131
+ console.log(code(' android:resource="@color/notification_tint"/>'))
132
+ }
133
+
134
+ logger.section('Next steps')
135
+ if (inPlace) {
136
+ console.log(` ${num('1.')} Preview in ${flag('Preview.app')} — files were overwritten directly.`)
137
+ console.log(` ${num('2.')} If something looks wrong: ${code('git checkout -- .')}`)
138
+ console.log(` ${num('3.')} Rebuild: ${code('ti clean && ti build -p android -T emulator')}`)
139
+ } else {
140
+ console.log(` ${num('1.')} Preview the generated icons, then copy to project (see Summary).`)
141
+ console.log(` ${num('2.')} Cleanup staging: ${code('rm -rf ' + stagingRoot)}`)
142
+ console.log(` ${num('3.')} Rebuild: ${code('ti clean && ti build -p android -T emulator')}`)
143
+ }
144
+ console.log()
145
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * PurgeTSS - prepare-master
3
+ *
4
+ * Produces two normalized masters from a single input:
5
+ *
6
+ * 1. <base>_square.png — 1024×1024 PNG, logo centered in a transparent square
7
+ * canvas. Used for iOS DefaultIcon + marketplace artwork.
8
+ *
9
+ * 2. <base>_tight.png — logo rasterized at 1024-px max dimension with native
10
+ * aspect preserved (no padding). Used for Android adaptive icons so a
11
+ * horizontal wordmark fills the safe-zone by width instead of being
12
+ * double-padded inside a square.
13
+ *
14
+ * Accepts SVG or PNG/JPG/WebP. SVG is rasterized by Sharp at high density,
15
+ * then downsampled to 1024 for clean high-DPI output.
16
+ *
17
+ * @fileoverview Master-image preparation for the branding pipeline
18
+ * @author César Estrada
19
+ */
20
+
21
+ import fs from 'fs'
22
+ import path from 'path'
23
+ import sharp from 'sharp'
24
+ import { logger } from './branding-logger.js'
25
+ import { computeSvgDensity, readSvgSafely } from '../../shared/svg-utils.js'
26
+
27
+ const MAX_DIMENSION = 1024
28
+
29
+ /**
30
+ * Prepare dual masters (square + tight) from a single input.
31
+ * @param {string} inputPath - Path to SVG or PNG master
32
+ * @param {string} basePath - Output base path (no extension, e.g. /tmp/foo/_master)
33
+ * @returns {Promise<{square: string, tight: string}>} Paths to both outputs
34
+ */
35
+ export async function prepareMaster(inputPath, basePath) {
36
+ const ext = path.extname(inputPath).toLowerCase().slice(1)
37
+ const squarePath = `${basePath}_square.png`
38
+ const tightPath = `${basePath}_tight.png`
39
+
40
+ fs.mkdirSync(path.dirname(basePath), { recursive: true })
41
+
42
+ if (ext === 'svg') {
43
+ await rasterizeSvgToTight(inputPath, tightPath)
44
+ } else if (ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'webp') {
45
+ await downsamplePngToTight(inputPath, tightPath)
46
+ } else {
47
+ throw new Error(`Unsupported master format: .${ext} (expected .svg or .png)`)
48
+ }
49
+
50
+ await padTightToSquare(tightPath, squarePath)
51
+
52
+ return { square: squarePath, tight: tightPath }
53
+ }
54
+
55
+ async function rasterizeSvgToTight(svgPath, outPath) {
56
+ const { buffer: svgBuffer, naturalMax } = await readSvgSafely(svgPath, {
57
+ logger,
58
+ withAdvice: true
59
+ })
60
+
61
+ // Supersample to ~4× MAX_DIMENSION so the final downsample yields clean edges.
62
+ const density = computeSvgDensity(naturalMax, MAX_DIMENSION * 4)
63
+ const hiRes = await sharp(svgBuffer, { density, limitInputPixels: false })
64
+ .png()
65
+ .toBuffer()
66
+
67
+ const meta = await sharp(hiRes).metadata()
68
+ const { width: w, height: h } = meta
69
+
70
+ await sharp(hiRes)
71
+ .resize({
72
+ width: w >= h ? MAX_DIMENSION : null,
73
+ height: h > w ? MAX_DIMENSION : null,
74
+ fit: 'inside',
75
+ withoutEnlargement: false
76
+ })
77
+ .png({ compressionLevel: 9 })
78
+ .toFile(outPath)
79
+ }
80
+
81
+ async function downsamplePngToTight(inputPath, outPath) {
82
+ const meta = await sharp(inputPath).metadata()
83
+ const { width: w, height: h } = meta
84
+
85
+ await sharp(inputPath)
86
+ .resize({
87
+ width: w >= h ? MAX_DIMENSION : null,
88
+ height: h > w ? MAX_DIMENSION : null,
89
+ fit: 'inside',
90
+ withoutEnlargement: true
91
+ })
92
+ .png({ compressionLevel: 9 })
93
+ .toFile(outPath)
94
+ }
95
+
96
+ async function padTightToSquare(tightPath, squarePath) {
97
+ await sharp(tightPath)
98
+ .resize({
99
+ width: MAX_DIMENSION,
100
+ height: MAX_DIMENSION,
101
+ fit: 'contain',
102
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
103
+ })
104
+ .png({ compressionLevel: 9 })
105
+ .toFile(squarePath)
106
+ }
107
+
108
+ export { MAX_DIMENSION }