fix dash style and wording in icon files
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cinch Icon - Final</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background:
|
||||
repeating-conic-gradient(#1a1a1a 0% 25%, #222 0% 50%) 50% / 20px 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
padding: 40px;
|
||||
}
|
||||
.icon-wrapper {
|
||||
filter: drop-shadow(0 6px 16px rgba(20, 184, 166, 0.3));
|
||||
padding: 80px;
|
||||
}
|
||||
.icon-container {
|
||||
width: 512px;
|
||||
height: 512px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
clip-path: url(#squircle);
|
||||
background: linear-gradient(135deg, #0a1628 0%, #14b8a6 100%);
|
||||
}
|
||||
.icon-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 50%;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255,255,255,0.15) 0%,
|
||||
rgba(255,255,255,0.03) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.icon {
|
||||
color: #fff;
|
||||
font-size: 240px;
|
||||
filter: drop-shadow(3px 6px 12px rgba(0,0,0,0.3));
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
<clipPath id="squircle" clipPathUnits="objectBoundingBox">
|
||||
<path d="M 0,0.5 C 0,0.112 0.112,0 0.5,0 0.888,0 1,0.112 1,0.5 1,0.888 0.888,1 0.5,1 0.112,1 0,0.888 0,0.5" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-container" id="icon-target">
|
||||
<i class="fa-solid fa-film icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="label">Cinch - 512x512px</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cinch Icon Variants</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
gap: 48px;
|
||||
padding: 40px;
|
||||
}
|
||||
h1 {
|
||||
color: #e5e5e5;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.comparison-grid {
|
||||
display: flex;
|
||||
gap: 64px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.variant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.variant-label {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.variant-label span {
|
||||
color: #14b8a6;
|
||||
}
|
||||
.icon-wrapper {
|
||||
filter: drop-shadow(0 12px 40px rgba(20, 184, 166, 0.35));
|
||||
}
|
||||
.icon-container {
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
clip-path: url(#squircle);
|
||||
}
|
||||
.icon-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 50%;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255,255,255,0.12) 0%,
|
||||
rgba(255,255,255,0.03) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.icon {
|
||||
color: #fff;
|
||||
font-size: 120px;
|
||||
filter: drop-shadow(2px 4px 8px rgba(0,0,0,0.25));
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Variant A - Compress */
|
||||
#variant-a {
|
||||
background: linear-gradient(135deg, #0d3b3b 0%, #0a1628 100%);
|
||||
}
|
||||
|
||||
/* Variant B - Bolt */
|
||||
#variant-b {
|
||||
background: linear-gradient(135deg, #0f2e2e 0%, #0d5c5c 100%);
|
||||
}
|
||||
|
||||
/* Variant C - Film */
|
||||
#variant-c {
|
||||
background: linear-gradient(135deg, #0a1628 0%, #14b8a6 100%);
|
||||
}
|
||||
|
||||
.icon-desc {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
max-width: 220px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
<clipPath id="squircle" clipPathUnits="objectBoundingBox">
|
||||
<path d="M 0,0.5 C 0,0.112 0.112,0 0.5,0 0.888,0 1,0.112 1,0.5 1,0.888 0.888,1 0.5,1 0.112,1 0,0.888 0,0.5" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<h1>Cinch - Icon Variants</h1>
|
||||
|
||||
<div class="comparison-grid">
|
||||
<div class="variant">
|
||||
<div class="variant-label"><span>A</span> - Compress</div>
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-container" id="variant-a">
|
||||
<i class="fa-solid fa-compress icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon-desc">Arrows pointing inward. Direct, literal representation of compression.</div>
|
||||
</div>
|
||||
|
||||
<div class="variant">
|
||||
<div class="variant-label"><span>B</span> - Bolt</div>
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-container" id="variant-b">
|
||||
<i class="fa-solid fa-bolt icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon-desc">Lightning bolt. Speed, power, effortless — "it's a cinch."</div>
|
||||
</div>
|
||||
|
||||
<div class="variant">
|
||||
<div class="variant-label"><span>C</span> - Film</div>
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-container" id="variant-c">
|
||||
<i class="fa-solid fa-film icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon-desc">Film strip. Clear video identity. Instantly recognizable.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* App Icon Export Script
|
||||
*
|
||||
* Takes an HTML icon preview file and exports it as:
|
||||
* - Multiple PNG sizes (default: 512, 256, 192, 128, 64, 48, 32, 16)
|
||||
* - Optionally an ICO file containing 16, 32, 48, 256px variants
|
||||
*
|
||||
* Because headless Chrome in this environment cannot access external CDNs,
|
||||
* this script downloads icon font CSS and font files via curl, converts
|
||||
* them to base64 data URIs, and inlines everything into a self-contained
|
||||
* HTML before screenshotting.
|
||||
*
|
||||
* Usage:
|
||||
* node export-icon.js --input icon.html --output ./out --name my-icon --sizes 512,256,128 --ico
|
||||
*
|
||||
* Dependencies: puppeteer-core, sharp, png-to-ico
|
||||
* npm install puppeteer-core sharp png-to-ico
|
||||
*/
|
||||
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Find a Chrome/Chromium binary on the system
|
||||
function findChrome() {
|
||||
const candidates = [
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/chromium',
|
||||
'/opt/google/chrome/chrome',
|
||||
'/opt/pw-browsers/chromium-1194/chrome-linux/chrome',
|
||||
];
|
||||
// Also check common Puppeteer cache locations
|
||||
const home = process.env.HOME || '/root';
|
||||
try {
|
||||
const cacheDir = path.join(home, '.cache', 'puppeteer', 'chrome');
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
const versions = fs.readdirSync(cacheDir).sort().reverse();
|
||||
for (const v of versions) {
|
||||
const chromePath = path.join(cacheDir, v, 'chrome-linux64', 'chrome');
|
||||
if (fs.existsSync(chromePath)) candidates.unshift(chromePath);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) {
|
||||
console.log(`Using Chrome: ${c}`);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
throw new Error('No Chrome/Chromium binary found. Tried:\n ' + candidates.join('\n ') + '\nSet CHROME_PATH env variable.');
|
||||
}
|
||||
const CHROME_PATH = process.env.CHROME_PATH || findChrome();
|
||||
|
||||
// Download a URL using curl (bypasses Chrome network restrictions in container)
|
||||
function curlDownload(url) {
|
||||
try {
|
||||
return execSync(`curl -sL --max-time 15 "${url}"`, { maxBuffer: 50 * 1024 * 1024 });
|
||||
} catch (e) {
|
||||
console.warn(` Warning: Failed to download: ${url}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a buffer to a base64 data URI
|
||||
function toDataUri(buffer, mimeType) {
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
// Inline all external CDN resources in the HTML so Chrome can render offline
|
||||
function inlineExternalResources(html) {
|
||||
console.log('Inlining external resources...');
|
||||
|
||||
// Find all <link rel="stylesheet" href="..."> tags (both orderings of attributes)
|
||||
const linkPattern = /<link[^>]*?href=["']([^"']+)["'][^>]*?rel=["']stylesheet["'][^>]*?>|<link[^>]*?rel=["']stylesheet["'][^>]*?href=["']([^"']+)["'][^>]*?>/gi;
|
||||
let match;
|
||||
const stylesheetEntries = [];
|
||||
|
||||
while ((match = linkPattern.exec(html)) !== null) {
|
||||
const href = match[1] || match[2];
|
||||
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
|
||||
stylesheetEntries.push({ fullMatch: match[0], url: href });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { fullMatch, url } of stylesheetEntries) {
|
||||
console.log(` Downloading CSS: ${url.split('/').slice(-2).join('/')}`);
|
||||
const cssBuffer = curlDownload(url);
|
||||
if (!cssBuffer) continue;
|
||||
|
||||
let css = cssBuffer.toString('utf8');
|
||||
|
||||
// Resolve relative URLs in the CSS to absolute
|
||||
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1);
|
||||
css = css.replace(/url\(["']?(\.\.\/[^"')]+)["']?\)/g, (full, relPath) => {
|
||||
try {
|
||||
const absUrl = new URL(relPath, url).href;
|
||||
return `url("${absUrl}")`;
|
||||
} catch {
|
||||
return full;
|
||||
}
|
||||
});
|
||||
|
||||
// Find all absolute url() references in the CSS
|
||||
const urlRegex = /url\(["']?(https?:\/\/[^"')]+)["']?\)/g;
|
||||
let urlMatch;
|
||||
const fontUrls = new Set();
|
||||
while ((urlMatch = urlRegex.exec(css)) !== null) {
|
||||
fontUrls.add(urlMatch[1]);
|
||||
}
|
||||
|
||||
// Download and inline each font/resource file
|
||||
for (const fontUrl of fontUrls) {
|
||||
const filename = fontUrl.split('/').pop().split('?')[0];
|
||||
console.log(` Downloading: ${filename}`);
|
||||
const fontBuffer = curlDownload(fontUrl);
|
||||
if (!fontBuffer) continue;
|
||||
|
||||
let mimeType = 'application/octet-stream';
|
||||
if (fontUrl.includes('.woff2')) mimeType = 'font/woff2';
|
||||
else if (fontUrl.includes('.woff')) mimeType = 'font/woff';
|
||||
else if (fontUrl.includes('.ttf')) mimeType = 'font/ttf';
|
||||
else if (fontUrl.includes('.eot')) mimeType = 'application/vnd.ms-fontobject';
|
||||
else if (fontUrl.includes('.svg')) mimeType = 'image/svg+xml';
|
||||
|
||||
const dataUri = toDataUri(fontBuffer, mimeType);
|
||||
css = css.split(fontUrl).join(dataUri);
|
||||
}
|
||||
|
||||
// Replace <link> tag with inline <style>
|
||||
html = html.replace(fullMatch, `<style>/* Inlined from: ${url.split('/').slice(-2).join('/')} */\n${css}\n</style>`);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Parse CLI arguments
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts = {
|
||||
input: null,
|
||||
output: null,
|
||||
name: 'app-icon',
|
||||
sizes: [512, 256, 192, 128, 64, 48, 32, 16],
|
||||
ico: false,
|
||||
shadowPadding: 40,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--input': opts.input = args[++i]; break;
|
||||
case '--output': opts.output = args[++i]; break;
|
||||
case '--name': opts.name = args[++i]; break;
|
||||
case '--sizes': opts.sizes = args[++i].split(',').map(Number).filter(n => n > 0); break;
|
||||
case '--preset':
|
||||
const preset = args[++i];
|
||||
if (preset === 'ios') opts.sizes = [1024, 512, 180, 167, 152, 120, 87, 80, 76, 60, 40, 29, 20];
|
||||
else if (preset === 'android') opts.sizes = [512, 432, 324, 216, 162, 108, 81, 48];
|
||||
else if (preset === 'favicon') opts.sizes = [512, 192, 180, 32, 16];
|
||||
else if (preset === 'all') opts.sizes = [1024, 512, 432, 324, 256, 216, 192, 180, 167, 152, 128, 120, 108, 96, 87, 81, 80, 76, 64, 60, 48, 40, 32, 29, 20, 16];
|
||||
break;
|
||||
case '--ico': opts.ico = true; break;
|
||||
case '--shadow-padding': opts.shadowPadding = parseInt(args[++i], 10); break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.input || !opts.output) {
|
||||
console.error('Usage: node export-icon.js --input <file> --output <dir> [--name <n>] [--sizes <csv>] [--ico] [--shadow-padding <px>]');
|
||||
process.exit(1);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function exportIcon(opts) {
|
||||
const { input, output, name, sizes, ico, shadowPadding } = opts;
|
||||
|
||||
if (!fs.existsSync(output)) fs.mkdirSync(output, { recursive: true });
|
||||
|
||||
console.log(`\nExporting icon from: ${input}`);
|
||||
console.log(`Output: ${output}`);
|
||||
console.log(`Sizes: ${sizes.join(', ')}px`);
|
||||
console.log(`Name: ${name}`);
|
||||
if (ico) console.log(`ICO: enabled\n`);
|
||||
|
||||
// Read and inline all external resources
|
||||
let html = fs.readFileSync(path.resolve(input), 'utf8');
|
||||
html = inlineExternalResources(html);
|
||||
|
||||
// Write self-contained HTML to temp file
|
||||
const tempHtml = path.join(output, '_export_temp.html');
|
||||
fs.writeFileSync(tempHtml, html, 'utf8');
|
||||
|
||||
// Launch headless Chrome
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
executablePath: CHROME_PATH,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--allow-file-access-from-files'],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const vpSize = 512 + (shadowPadding * 2) + 100;
|
||||
await page.setViewport({ width: vpSize, height: vpSize, deviceScaleFactor: 2 });
|
||||
|
||||
await page.goto(`file://${path.resolve(tempHtml)}`, { waitUntil: 'networkidle0', timeout: 15000 });
|
||||
|
||||
// Wait for inlined fonts to render - try fonts.ready then fallback to timeout
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
// Additional wait for icon fonts which may load async after fonts.ready
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const fontCount = await page.evaluate(() => document.fonts.size);
|
||||
const fontsLoaded = await page.evaluate(() =>
|
||||
Array.from(document.fonts).filter(f => f.status === 'loaded').length
|
||||
);
|
||||
console.log(`Fonts: ${fontsLoaded} loaded of ${fontCount} total`);
|
||||
|
||||
// Remove preview checkerboard background
|
||||
await page.evaluate(() => {
|
||||
document.body.style.background = 'transparent';
|
||||
document.body.style.backgroundImage = 'none';
|
||||
document.body.style.backgroundColor = 'transparent';
|
||||
document.documentElement.style.background = 'transparent';
|
||||
});
|
||||
|
||||
// Find target element
|
||||
let el = await page.$('.icon-wrapper') || await page.$('#icon-target') || await page.$('.icon-container');
|
||||
if (!el) throw new Error('No .icon-wrapper, #icon-target, or .icon-container found');
|
||||
|
||||
// Screenshot with transparent background
|
||||
const screenshot = await el.screenshot({ type: 'png', omitBackground: true });
|
||||
console.log(`Screenshot captured`);
|
||||
|
||||
const meta = await sharp(screenshot).metadata();
|
||||
console.log(`Raw: ${meta.width}x${meta.height}px\n`);
|
||||
|
||||
// Generate all sizes
|
||||
const files = [];
|
||||
const icoBufs = {};
|
||||
|
||||
for (const size of sizes.sort((a, b) => b - a)) {
|
||||
const fn = `${name}-${size}x${size}.png`;
|
||||
const fp = path.join(output, fn);
|
||||
await sharp(screenshot).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(fp);
|
||||
files.push(fp);
|
||||
console.log(` ${fn}`);
|
||||
if ([16, 24, 32, 48, 64, 128, 256].includes(size)) {
|
||||
icoBufs[size] = await sharp(screenshot).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// Main icon (largest size, no size suffix)
|
||||
const mainFp = path.join(output, `${name}.png`);
|
||||
const largest = Math.max(...sizes);
|
||||
await sharp(screenshot).resize(largest, largest, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(mainFp);
|
||||
files.unshift(mainFp);
|
||||
console.log(` ${name}.png (main)`);
|
||||
|
||||
// ICO generation
|
||||
if (ico) {
|
||||
const pngToIcoMod = require('png-to-ico');
|
||||
const pngToIco = pngToIcoMod.default || pngToIcoMod.imagesToIco || pngToIcoMod;
|
||||
const icoFp = path.join(output, `${name}.ico`);
|
||||
const icoSizes = [256, 48, 32, 16];
|
||||
const bufs = [];
|
||||
|
||||
for (const s of icoSizes) {
|
||||
bufs.push(icoBufs[s] || await sharp(screenshot).resize(s, s, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer());
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(icoFp, await pngToIco(bufs));
|
||||
files.push(icoFp);
|
||||
console.log(` ${name}.ico`);
|
||||
} catch (e) {
|
||||
console.warn(` ICO failed: ${e.message}, trying 32px only...`);
|
||||
try {
|
||||
const b = await sharp(screenshot).resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
|
||||
fs.writeFileSync(icoFp, await pngToIco([b]));
|
||||
files.push(icoFp);
|
||||
console.log(` ${name}.ico (32px only)`);
|
||||
} catch (e2) {
|
||||
console.error(` ICO failed completely: ${e2.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
try { fs.unlinkSync(tempHtml); } catch (e) { /* ok */ }
|
||||
|
||||
console.log(`\nDone! ${files.length} files exported.`);
|
||||
return files;
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
const opts = parseArgs();
|
||||
exportIcon(opts).catch(err => { console.error('Export failed:', err.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user