@niro53/store-tools 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/bin/cli.js +17 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +207 -0
- package/dist/config.d.ts +234 -0
- package/dist/config.js +71 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/metadata.d.ts +8 -0
- package/dist/metadata.js +85 -0
- package/dist/render.d.ts +13 -0
- package/dist/render.js +122 -0
- package/dist/validate.d.ts +19 -0
- package/dist/validate.js +109 -0
- package/package.json +50 -0
- package/templates/helpers.js +105 -0
- package/templates/styles.css +1243 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { chromium } from '@playwright/test';
|
|
2
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const TEMPLATES_DIR = join(__dirname, '..', 'templates');
|
|
7
|
+
// ── Platform sizes ────────────────────────────────────────────────────────────
|
|
8
|
+
const APPSTORE_W = 1320;
|
|
9
|
+
const APPSTORE_H = 2868; // iPhone 6.9" required (iPhone 16 Pro Max)
|
|
10
|
+
const PLAY_H = 2640; // Google Play max 2:1 clip
|
|
11
|
+
const FEATURE_W = 1024;
|
|
12
|
+
const FEATURE_H = 500;
|
|
13
|
+
// ── Per-scene render ──────────────────────────────────────────────────────────
|
|
14
|
+
let _sharedCss;
|
|
15
|
+
let _helpersJs;
|
|
16
|
+
async function getSharedAssets() {
|
|
17
|
+
_sharedCss ??= await readFile(join(TEMPLATES_DIR, 'styles.css'), 'utf-8');
|
|
18
|
+
_helpersJs ??= await readFile(join(TEMPLATES_DIR, 'helpers.js'), 'utf-8');
|
|
19
|
+
return { css: _sharedCss, js: _helpersJs };
|
|
20
|
+
}
|
|
21
|
+
async function renderScene(browser, htmlPath, locale, outFile, scene, appStylesPath) {
|
|
22
|
+
const isFeature = scene.kind === 'feature';
|
|
23
|
+
const vw = isFeature ? FEATURE_W : APPSTORE_W;
|
|
24
|
+
const vh = isFeature ? FEATURE_H : APPSTORE_H;
|
|
25
|
+
const { css: sharedCss, js: helpersJs } = await getSharedAssets();
|
|
26
|
+
const ctx = await browser.newContext({ viewport: { width: vw, height: vh }, deviceScaleFactor: 1 });
|
|
27
|
+
const page = await ctx.newPage();
|
|
28
|
+
// Inject shared helpers before any page scripts run.
|
|
29
|
+
await page.addInitScript({ content: helpersJs });
|
|
30
|
+
const url = `file://${htmlPath}?locale=${locale}`;
|
|
31
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
32
|
+
// Inject shared CSS (before optional per-app overrides).
|
|
33
|
+
await page.addStyleTag({ content: sharedCss });
|
|
34
|
+
if (appStylesPath) {
|
|
35
|
+
try {
|
|
36
|
+
const appCss = await readFile(appStylesPath, 'utf-8');
|
|
37
|
+
await page.addStyleTag({ content: appCss });
|
|
38
|
+
}
|
|
39
|
+
catch { /* no per-app override, fine */ }
|
|
40
|
+
}
|
|
41
|
+
await page.waitForFunction(() => window['__SCENE_READY'] === true, { timeout: 15_000 });
|
|
42
|
+
const selector = isFeature ? '.feature' : '.screen';
|
|
43
|
+
const el = await page.$(selector);
|
|
44
|
+
if (!el)
|
|
45
|
+
throw new Error(`Selector "${selector}" not found in ${htmlPath}`);
|
|
46
|
+
await el.screenshot({ path: outFile, omitBackground: false });
|
|
47
|
+
await ctx.close();
|
|
48
|
+
}
|
|
49
|
+
async function renderScenePlay(browser, htmlPath, locale, outFile, appStylesPath) {
|
|
50
|
+
// Render at full App Store size then clip the top PLAY_H pixels.
|
|
51
|
+
const { css: sharedCss, js: helpersJs } = await getSharedAssets();
|
|
52
|
+
const ctx = await browser.newContext({ viewport: { width: APPSTORE_W, height: APPSTORE_H }, deviceScaleFactor: 1 });
|
|
53
|
+
const page = await ctx.newPage();
|
|
54
|
+
await page.addInitScript({ content: helpersJs });
|
|
55
|
+
const url = `file://${htmlPath}?locale=${locale}`;
|
|
56
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
57
|
+
await page.addStyleTag({ content: sharedCss });
|
|
58
|
+
if (appStylesPath) {
|
|
59
|
+
try {
|
|
60
|
+
const appCss = await readFile(appStylesPath, 'utf-8');
|
|
61
|
+
await page.addStyleTag({ content: appCss });
|
|
62
|
+
}
|
|
63
|
+
catch { /* no per-app override */ }
|
|
64
|
+
}
|
|
65
|
+
await page.waitForFunction(() => window['__SCENE_READY'] === true, { timeout: 15_000 });
|
|
66
|
+
await page.screenshot({
|
|
67
|
+
path: outFile,
|
|
68
|
+
clip: { x: 0, y: 0, width: APPSTORE_W, height: PLAY_H },
|
|
69
|
+
omitBackground: false,
|
|
70
|
+
});
|
|
71
|
+
await ctx.close();
|
|
72
|
+
}
|
|
73
|
+
export async function renderScreenshots({ appDir, config, locales, scenes: sceneFilter, platforms = ['appstore', 'play'], verbose = true, }) {
|
|
74
|
+
const sourceDir = join(appDir, 'store-assets', 'source');
|
|
75
|
+
const outRoot = join(appDir, 'store-assets', 'rendered');
|
|
76
|
+
const appStylesPath = join(sourceDir, 'styles.css'); // optional per-app overrides
|
|
77
|
+
const targetLocales = locales ?? Object.keys(config.locales);
|
|
78
|
+
const targetScenes = sceneFilter
|
|
79
|
+
? config.scenes.filter((s) => sceneFilter.includes(s.out))
|
|
80
|
+
: config.scenes;
|
|
81
|
+
const log = (msg) => { if (verbose)
|
|
82
|
+
process.stdout.write(msg + '\n'); };
|
|
83
|
+
const browser = await chromium.launch({ args: ['--font-render-hinting=none'] });
|
|
84
|
+
try {
|
|
85
|
+
let count = 0;
|
|
86
|
+
for (const locale of targetLocales) {
|
|
87
|
+
// ── App Store ──────────────────────────────────────────────────────────
|
|
88
|
+
if (platforms.includes('appstore')) {
|
|
89
|
+
const localeDir = join(outRoot, locale);
|
|
90
|
+
await mkdir(localeDir, { recursive: true });
|
|
91
|
+
for (const scene of targetScenes.filter((s) => s.kind !== 'feature')) {
|
|
92
|
+
const htmlPath = join(sourceDir, scene.html);
|
|
93
|
+
const outFile = join(localeDir, `${scene.out}.png`);
|
|
94
|
+
await renderScene(browser, htmlPath, locale, outFile, scene, appStylesPath);
|
|
95
|
+
log(` ✓ ${resolve(appDir, '..', '..').split('/').at(-1) ?? ''}/${locale}/${scene.out}.png`);
|
|
96
|
+
count++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// ── Google Play screens ────────────────────────────────────────────────
|
|
100
|
+
if (platforms.includes('play')) {
|
|
101
|
+
const playDir = join(outRoot, `${locale}-play`);
|
|
102
|
+
await mkdir(playDir, { recursive: true });
|
|
103
|
+
for (const scene of targetScenes) {
|
|
104
|
+
const htmlPath = join(sourceDir, scene.html);
|
|
105
|
+
const outFile = join(playDir, `${scene.out}.png`);
|
|
106
|
+
if (scene.kind === 'feature') {
|
|
107
|
+
await renderScene(browser, htmlPath, locale, outFile, scene, appStylesPath);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
await renderScenePlay(browser, htmlPath, locale, outFile, appStylesPath);
|
|
111
|
+
}
|
|
112
|
+
log(` ✓ ${locale}-play/${scene.out}.png`);
|
|
113
|
+
count++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
log(`\n✓ Rendered ${count} images → store-assets/rendered/`);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await browser.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { StoreConfig } from './config.js';
|
|
2
|
+
export interface ValidationError {
|
|
3
|
+
field: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ValidationResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
errors: ValidationError[];
|
|
9
|
+
}
|
|
10
|
+
export declare function validateConfig(appDir: string, config: StoreConfig, opts?: {
|
|
11
|
+
checkSceneFiles?: boolean;
|
|
12
|
+
}): Promise<ValidationResult>;
|
|
13
|
+
/** Validate all apps in the monorepo — used by CI (`pnpm store validate --all`). */
|
|
14
|
+
export declare function validateAll(appDirs: {
|
|
15
|
+
appDir: string;
|
|
16
|
+
config: StoreConfig;
|
|
17
|
+
}[], opts?: {
|
|
18
|
+
checkSceneFiles?: boolean;
|
|
19
|
+
}): Promise<boolean>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { StoreConfigSchema } from './config.js';
|
|
4
|
+
// ── Uniqueness registry (cross-app duplicate guard, populated per run) ────────
|
|
5
|
+
const registry = new Map(); // "field:value" → slug
|
|
6
|
+
function checkUnique(slug, field, value, errors) {
|
|
7
|
+
const key = `${field}:${value.toLowerCase().trim()}`;
|
|
8
|
+
const existing = registry.get(key);
|
|
9
|
+
if (existing && existing !== slug) {
|
|
10
|
+
errors.push({ field, message: `Duplicate ${field} across apps "${slug}" and "${existing}" (Apple 4.3 shell-app risk)` });
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
registry.set(key, slug);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// ── Per-app validation ────────────────────────────────────────────────────────
|
|
17
|
+
export async function validateConfig(appDir, config, opts = {}) {
|
|
18
|
+
const errors = [];
|
|
19
|
+
// 1. Zod parse (re-validates; surfaces any schema errors cleanly)
|
|
20
|
+
const parsed = StoreConfigSchema.safeParse(config);
|
|
21
|
+
if (!parsed.success) {
|
|
22
|
+
for (const issue of parsed.error.issues) {
|
|
23
|
+
errors.push({ field: issue.path.join('.'), message: issue.message });
|
|
24
|
+
}
|
|
25
|
+
return { ok: false, errors };
|
|
26
|
+
}
|
|
27
|
+
// 2. Per-locale completeness (shortDescription recommended for Play)
|
|
28
|
+
for (const [locale, meta] of Object.entries(config.locales)) {
|
|
29
|
+
if (!meta.shortDescription) {
|
|
30
|
+
errors.push({
|
|
31
|
+
field: `locales.${locale}.shortDescription`,
|
|
32
|
+
message: 'Recommended for Google Play; missing will use the first paragraph of description',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!meta.subtitle) {
|
|
36
|
+
errors.push({
|
|
37
|
+
field: `locales.${locale}.subtitle`,
|
|
38
|
+
message: 'Recommended for App Store; missing = no subtitle shown in search results',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (!meta.releaseNotes) {
|
|
42
|
+
errors.push({
|
|
43
|
+
field: `locales.${locale}.releaseNotes`,
|
|
44
|
+
message: 'Missing release notes — upload will use last submitted notes',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// 3. Cross-app duplicate detection (Apple guideline 4.3)
|
|
49
|
+
for (const [locale, meta] of Object.entries(config.locales)) {
|
|
50
|
+
checkUnique(config.slug, `locales.${locale}.name`, meta.name, errors);
|
|
51
|
+
if (meta.subtitle)
|
|
52
|
+
checkUnique(config.slug, `locales.${locale}.subtitle`, meta.subtitle, errors);
|
|
53
|
+
}
|
|
54
|
+
// 4. Scene HTML file existence
|
|
55
|
+
if (opts.checkSceneFiles) {
|
|
56
|
+
const sourceDir = join(appDir, 'store-assets', 'source');
|
|
57
|
+
await Promise.all(config.scenes.map(async (scene) => {
|
|
58
|
+
try {
|
|
59
|
+
await access(join(sourceDir, scene.html));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
errors.push({
|
|
63
|
+
field: `scenes[${scene.out}].html`,
|
|
64
|
+
message: `Scene file not found: ${join(sourceDir, scene.html)}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
// 5. Must have at least one screen scene
|
|
70
|
+
const hasScreen = config.scenes.some((s) => s.kind === 'screen');
|
|
71
|
+
if (!hasScreen) {
|
|
72
|
+
errors.push({ field: 'scenes', message: 'At least one scene with kind="screen" is required' });
|
|
73
|
+
}
|
|
74
|
+
// 6. Apple: App Store requires appleId + itcTeamId for release uploads
|
|
75
|
+
if (!config.appleId) {
|
|
76
|
+
errors.push({ field: 'appleId', message: 'appleId is required for App Store uploads' });
|
|
77
|
+
}
|
|
78
|
+
if (!config.itcTeamId) {
|
|
79
|
+
errors.push({ field: 'itcTeamId', message: 'itcTeamId is required for App Store uploads' });
|
|
80
|
+
}
|
|
81
|
+
// Treat missing optional fields (2–3 above) as warnings, not hard failures
|
|
82
|
+
const hardErrors = errors.filter((e) => !e.field.includes('shortDescription') &&
|
|
83
|
+
!e.field.includes('subtitle') &&
|
|
84
|
+
!e.field.includes('releaseNotes') &&
|
|
85
|
+
!e.field.includes('appleId') &&
|
|
86
|
+
!e.field.includes('itcTeamId'));
|
|
87
|
+
return { ok: hardErrors.length === 0, errors };
|
|
88
|
+
}
|
|
89
|
+
/** Validate all apps in the monorepo — used by CI (`pnpm store validate --all`). */
|
|
90
|
+
export async function validateAll(appDirs, opts = {}) {
|
|
91
|
+
registry.clear(); // reset cross-app uniqueness per run
|
|
92
|
+
let allOk = true;
|
|
93
|
+
for (const { appDir, config } of appDirs) {
|
|
94
|
+
const result = await validateConfig(appDir, config, opts);
|
|
95
|
+
if (result.errors.length > 0) {
|
|
96
|
+
console.log(`\n${config.slug}:`);
|
|
97
|
+
for (const err of result.errors) {
|
|
98
|
+
const prefix = result.ok ? ' ⚠' : ' ✗';
|
|
99
|
+
console.log(`${prefix} ${err.field}: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.log(` ✓ ${config.slug} — OK`);
|
|
104
|
+
}
|
|
105
|
+
if (!result.ok)
|
|
106
|
+
allOk = false;
|
|
107
|
+
}
|
|
108
|
+
return allOk;
|
|
109
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@niro53/store-tools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Apple App Store + Google Play screenshot rendering, metadata sync, and fastlane upload pipeline for Capacitor apps.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"niro-store": "bin/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"templates",
|
|
20
|
+
"bin"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "vitest run --passWithNoTests",
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"prepublishOnly": "pnpm build"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"commander": "^12.1.0",
|
|
33
|
+
"zod": "^3.24.4"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@niro/tsconfig": "workspace:*",
|
|
37
|
+
"@playwright/test": "^1.52.0",
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"typescript": "^5.8.3",
|
|
40
|
+
"vitest": "^3.2.1"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@playwright/test": ">=1.40.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@playwright/test": {
|
|
47
|
+
"optional": false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* @niro/store-tools — shared scene helpers
|
|
4
|
+
* Injected by render.ts via Playwright addInitScript BEFORE the page scripts.
|
|
5
|
+
*
|
|
6
|
+
* Exposes: window.STORE (utility namespace)
|
|
7
|
+
* STORE.locale — resolved locale string ("en", "cs", "pl", …)
|
|
8
|
+
* STORE.multiline(s) — replace \n in translation strings with <br>
|
|
9
|
+
* STORE.statusBarHTML() — iOS-style "9:41" status bar HTML
|
|
10
|
+
* STORE.bottomNavHTML(items, activeIndex) — bottom-nav HTML
|
|
11
|
+
* STORE.serviceLogo(name, size, opts) — service logo tile HTML (optional)
|
|
12
|
+
* STORE.brandLogoSVG(svgContent) — wrapped brand logo
|
|
13
|
+
*
|
|
14
|
+
* Service logos / brand colors are optional — only apps that show service
|
|
15
|
+
* tiles need to populate window.SERVICE_BG / window.SERVICE_LOGO_SVG.
|
|
16
|
+
*/
|
|
17
|
+
(function () {
|
|
18
|
+
/* ─── Locale detection ─────────────────────────────────────────────── */
|
|
19
|
+
const params = new URLSearchParams(window.location.search);
|
|
20
|
+
const raw = (params.get('locale') || 'en').toLowerCase();
|
|
21
|
+
window.LOCALE = raw;
|
|
22
|
+
|
|
23
|
+
/* ─── STORE namespace ──────────────────────────────────────────────── */
|
|
24
|
+
window.STORE = {
|
|
25
|
+
get locale() { return window.LOCALE; },
|
|
26
|
+
|
|
27
|
+
/** Replace literal \n (or \\n) with <br> — for multi-line headline strings. */
|
|
28
|
+
multiline(str) {
|
|
29
|
+
if (!str) return '';
|
|
30
|
+
return String(str).replace(/\\n|\n/g, '<br>');
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
/** iOS-style "9:41 ▪ wifi ▪ battery" status bar. */
|
|
34
|
+
statusBarHTML() {
|
|
35
|
+
return `
|
|
36
|
+
<div class="statusbar">
|
|
37
|
+
<span>9:41</span>
|
|
38
|
+
<div class="icons">
|
|
39
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
|
40
|
+
<path d="M1.333 7.46a13.81 13.81 0 0 1 21.334 0l-1.77 1.77a11.31 11.31 0 0 0-17.794 0L1.333 7.46z"/>
|
|
41
|
+
<path d="M5.333 11.46a8.318 8.318 0 0 1 13.334 0l-1.77 1.77a5.818 5.818 0 0 0-9.794 0l-1.77-1.77z"/>
|
|
42
|
+
<path d="M12 17a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
|
|
43
|
+
</svg>
|
|
44
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
|
45
|
+
<rect x="1" y="7" width="18" height="10" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
|
46
|
+
<rect x="19.5" y="10" width="2.5" height="4" rx="1" fill="currentColor"/>
|
|
47
|
+
<rect x="2.5" y="8.5" width="14" height="7" rx="1" fill="currentColor"/>
|
|
48
|
+
</svg>
|
|
49
|
+
</div>
|
|
50
|
+
</div>`;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generic bottom nav.
|
|
55
|
+
* @param items Array of { label, icon (SVG string) }
|
|
56
|
+
* @param active Zero-based active index, or -1 to put FAB (+ button) in the centre.
|
|
57
|
+
*/
|
|
58
|
+
bottomNavHTML(items, active) {
|
|
59
|
+
if (!items || items.length === 0) return '';
|
|
60
|
+
return `
|
|
61
|
+
<div class="bottom-nav">
|
|
62
|
+
${items.map((item, i) => `
|
|
63
|
+
<div class="item ${i === active ? 'active' : ''}">
|
|
64
|
+
${item.icon || ''}
|
|
65
|
+
<span>${item.label || ''}</span>
|
|
66
|
+
</div>
|
|
67
|
+
`).join('')}
|
|
68
|
+
</div>`;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Service logo tile HTML. Requires window.SERVICE_BG + window.SERVICE_LOGO_SVG.
|
|
73
|
+
* @param name Service name (must be a key in SERVICE_BG / SERVICE_LOGO_SVG)
|
|
74
|
+
* @param size Tile size in px (default 84)
|
|
75
|
+
* @param opts { radius: borderRadius in px }
|
|
76
|
+
*/
|
|
77
|
+
serviceLogo(name, size = 84, opts = {}) {
|
|
78
|
+
const bg = (window.SERVICE_BG || {})[name] || '#333';
|
|
79
|
+
const svg = (window.SERVICE_LOGO_SVG || {})[name] || '';
|
|
80
|
+
const r = opts.radius ?? 14;
|
|
81
|
+
const s = Number(size);
|
|
82
|
+
const iconSize = Math.round(s * 0.55);
|
|
83
|
+
return `
|
|
84
|
+
<div class="logo" style="
|
|
85
|
+
background:${bg};
|
|
86
|
+
width:${s}px;height:${s}px;
|
|
87
|
+
border-radius:${r}px;
|
|
88
|
+
display:flex;align-items:center;justify-content:center;
|
|
89
|
+
flex-shrink:0;">
|
|
90
|
+
${svg.replace(/viewBox="0 0 24 24">/, `viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}">`)}
|
|
91
|
+
</div>`;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Wrap an SVG string in a standardised brand-logo container.
|
|
96
|
+
* @param svgContent Raw <svg …>…</svg> string
|
|
97
|
+
*/
|
|
98
|
+
brandLogoSVG(svgContent) {
|
|
99
|
+
return svgContent || '';
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/* ─── Scene readiness ──────────────────────────────────────────────── */
|
|
104
|
+
window.__SCENE_READY = false;
|
|
105
|
+
})();
|