Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 85 additions & 41 deletions bin/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import { join } from 'node:path';

import { openaiImages } from '../src/emoji.ts';
import { resolveCredentials } from '../src/credentials.ts';
import { HQ_CONCURRENCY, HQ_STYLE, buildHq, drawHq, guardCredits, withHq } from '../src/icon-hq.ts';
import { HQ_CONCURRENCY, buildHq, drawHq, guardCredits, withStyle } from '../src/icon-hq.ts';
import { AGENTIC_STYLES, STYLES, type StyleSpec, resolveStyle } from '../src/icon-styles.ts';
import { isMain } from '../src/is-main.ts';

const USAGE = `Usage:
Expand All @@ -40,15 +41,30 @@ const USAGE = `Usage:
icon show <name>
icon search <words…>
icon glyph <name> [--mode nerd|unicode|ascii]
icon hq [--out DIR] [--only mail,…] [--no-draw] [--force] [--concurrency N]
[--quality low|medium|high] [--style-dir DIR] [--dry-run]
icon hq [DRAW OPTIONS]
icon agentic [matte|machined|emissive|all] [DRAW OPTIONS]
icon style <${Object.keys(STYLES).join('|')}|agentic|all> [DRAW OPTIONS]

\`hq\` makes the optional HQ style: every UI icon drawn in full colour by an
image model (needs OPENAI_API_KEY), each pinned to its own simple line glyph
and to OpenEmoji masters for the look (--style-dir, default
~/brand-assets/openemoji/master); brands in their owners' colours. Masters go
to hq/master and are never redrawn; --no-draw only rebuilds sizes and the
manifest. The simple style stays the default and canonical.
DRAW OPTIONS: [--out DIR] [--only mail,…] [--no-draw] [--draw-only] [--force]
[--concurrency N] [--quality low|medium|high]
[--style-dir DIR] [--dry-run]

A colour style draws every UI icon with an image model (needs OPENAI_API_KEY),
each pinned to its own simple line glyph so the silhouette and meaning hold;
brands are never drawn, they take their owners' colours.

hq glass and enamel under a warm key light, drawn beside the OpenEmoji
masters (--style-dir, default ~/brand-assets/openemoji/master)
agentic the three flat-material styles, which take no emoji reference:
matte (the default, and the one that holds at 20px on any ground),
machined, emissive

Masters go to <style-dir>/master and are never redrawn; --no-draw only
rebuilds sizes and the manifest, and --draw-only only draws. Drawing a whole
style takes hours, so the pair is how several styles are drawn at once: one
--draw-only run per style, then a single --no-draw pass to derive them, which
keeps one writer on openicon.json. The simple style stays the default and
canonical.

A name is a key (mail) or an alias (email). \`glyph\` picks Nerd Font, then
Unicode, then ASCII from $OPENICON_GLYPHS, $NERD_FONT, or the terminal
Expand Down Expand Up @@ -83,30 +99,36 @@ export function glyphMode(env: NodeJS.ProcessEnv = process.env): 'nerd' | 'unico
return /utf-?8/i.test(locale) || env['TERM_PROGRAM'] ? 'unicode' : 'ascii';
}

/** Derive the HQ sizes and brand colours, and write the `hq` block into openicon.json. */
export async function applyHq(out: string, sizes: number[]): Promise<number> {
/** Derive one style's sizes and brand colours, and write its block into openicon.json. */
export async function applyHq(out: string, sizes: number[], style: StyleSpec = STYLES.hq!): Promise<number> {
const { Resvg } = await import('@resvg/resvg-js');
const entries = await buildHq({
out,
sizes,
style,
log: (line) => process.stderr.write(`${line}\n`),
render: (svg, size) => Buffer.from(new Resvg(svg, { fitTo: { mode: 'width', value: size } }).render().asPng()),
});
const file = join(out, 'openicon.json');
const manifest = withHq(JSON.parse(await readFile(file, 'utf8')), entries, sizes);
const manifest = withStyle(JSON.parse(await readFile(file, 'utf8')), style, entries, sizes);
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`);
await writeFile(join(out, 'hq', 'style.txt'), `${HQ_STYLE}\n`);
await writeFile(join(out, style.dir, 'style.txt'), `${style.styleText}\n`);
return entries.size;
}

/** The styles a set already carries on disk, so a rebuild keeps every one of them. */
export function stylesPresent(out: string): StyleSpec[] {
return Object.values(STYLES).filter((style) => existsSync(join(out, style.dir, 'png')));
}

if (isMain(import.meta.url)) {
process.stdout.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EPIPE') process.exit(0);
throw error;
});
try {
const { flags, values, positional } = parseArgs(process.argv.slice(2), {
boolean: ['--json', '--help', '--no-draw', '--force', '--dry-run'],
boolean: ['--json', '--help', '--no-draw', '--draw-only', '--force', '--dry-run'],
string: ['-o', '--out', '--sizes', '--color', '--only', '--category', '--mode', '--concurrency', '--quality', '--style-dir'],
});
const [verb, ...rest] = positional;
Expand All @@ -133,8 +155,8 @@ if (isMain(import.meta.url)) {
only: csv(values, '--only'),
log: (line) => process.stderr.write(`${line}\n`),
});
// An existing HQ style survives a rebuild of the simple one.
if (existsSync(join(out, 'hq', 'png'))) await applyHq(out, sizes);
// Existing colour styles survive a rebuild of the simple one.
for (const style of stylesPresent(out)) await applyHq(out, sizes, style);
const nerd = manifest.icons.filter((i) => i.tui.nerd).length;
process.stdout.write(
`${manifest.icons.length} icons in ${out} (${nerd} with a Nerd Font glyph); open ${out}/index.html\n`,
Expand Down Expand Up @@ -186,7 +208,19 @@ if (isMain(import.meta.url)) {
}
break;
}
case 'hq': {
case 'hq':
case 'agentic':
case 'style': {
// `icon hq` is `icon style hq`; `icon agentic X` is `icon style agentic-X`.
const asked =
verb === 'hq' ? 'hq'
: verb === 'agentic' ? (rest[0] ? (rest[0] === 'all' ? 'all' : `agentic-${rest[0]}`) : 'agentic')
: (rest[0] ?? '');
if (!asked) throw new UsageError(`style needs a name (${Object.keys(STYLES).join(', ')}, agentic, all)`);
const chosen: StyleSpec[] =
asked === 'all' ? (verb === 'agentic' ? [...AGENTIC_STYLES] : Object.keys(STYLES)).map((id) => resolveStyle(id))
: [resolveStyle(asked)];

const out = values.get('-o') ?? values.get('--out') ?? DEFAULT_OUT;
const manifestFile = join(out, 'openicon.json');
if (!existsSync(manifestFile)) throw new UsageError(`no ${manifestFile}; run \`icon build --out ${out}\` first`);
Expand All @@ -196,35 +230,45 @@ if (isMain(import.meta.url)) {
const drawable = keys.filter((k) => GENERIC.some((i) => i.key === k));
const quality = values.get('--quality') ?? 'medium';
if (!['low', 'medium', 'high'].includes(quality)) throw new UsageError('--quality must be low, medium or high');
const pending = drawable.filter((k) => flags.has('--force') || !existsSync(join(out, 'hq', 'master', `${k}.png`)));

if (flags.has('--dry-run')) {
process.stdout.write(`${flags.has('--no-draw') ? 0 : pending.length} icons to draw, then sizes, brand colours and openicon.json in ${out}\n`);
for (const style of chosen) {
const pending = drawable.filter((k) => flags.has('--force') || !existsSync(join(out, style.dir, 'master', `${k}.png`)));
process.stdout.write(`${style.id}: ${flags.has('--no-draw') ? 0 : pending.length} icons to draw, then sizes, brand colours and openicon.json in ${out}\n`);
}
break;
}
if (!flags.has('--no-draw') && pending.length) {
const key = resolveCredentials()['OPENAI_API_KEY'];
if (!key) throw new Error('no OpenAI key: export OPENAI_API_KEY or run `cli-tools config set openai`');
const { Resvg } = await import('@resvg/resvg-js');
const report = await drawHq({
out,
keys: drawable,
caller: guardCredits(openaiImages(key)),
styleDir: values.get('--style-dir') ?? join(process.env['HOME'] ?? '', 'brand-assets', 'openemoji', 'master'),
concurrency: Number(values.get('--concurrency') ?? HQ_CONCURRENCY),
quality,
force: flags.has('--force'),
log: (line) => process.stderr.write(`${line}\n`),
render: (svg, size) => Buffer.from(new Resvg(svg, { fitTo: { mode: 'width', value: size } }).render().asPng()),
});
process.stderr.write(`drew ${report.drawn.length} in ${report.calls} calls, ${report.failed.length} failed\n`);
if (report.stoppedForCredit) {
process.stderr.write(`stopped: the OpenAI account is out of credit (${report.stoppedForCredit})\n`);
process.exitCode = 2;

for (const style of chosen) {
const pending = drawable.filter((k) => flags.has('--force') || !existsSync(join(out, style.dir, 'master', `${k}.png`)));
if (!flags.has('--no-draw') && pending.length) {
const key = resolveCredentials()['OPENAI_API_KEY'];
if (!key) throw new Error('no OpenAI key: export OPENAI_API_KEY or run `cli-tools config set openai`');
const { Resvg } = await import('@resvg/resvg-js');
const report = await drawHq({
out,
style,
keys: drawable,
caller: guardCredits(openaiImages(key)),
styleDir: values.get('--style-dir') ?? join(process.env['HOME'] ?? '', 'brand-assets', 'openemoji', 'master'),
concurrency: Number(values.get('--concurrency') ?? HQ_CONCURRENCY),
quality,
force: flags.has('--force'),
log: (line) => process.stderr.write(`${line}\n`),
render: (svg, size) => Buffer.from(new Resvg(svg, { fitTo: { mode: 'width', value: size } }).render().asPng()),
});
process.stderr.write(`${style.id}: drew ${report.drawn.length} in ${report.calls} calls, ${report.failed.length} failed\n`);
if (report.stoppedForCredit) {
process.stderr.write(`stopped: the OpenAI account is out of credit (${report.stoppedForCredit})\n`);
process.exitCode = 2;
break;
}
}
if (flags.has('--draw-only')) continue;
const manifest = JSON.parse(await readFile(manifestFile, 'utf8'));
const done = await applyHq(out, manifest.sizes, style);
process.stdout.write(`${done}/${manifest.icons.length} icons have the ${style.id} style in ${out}\n`);
}
const manifest = JSON.parse(await readFile(manifestFile, 'utf8'));
const done = await applyHq(out, manifest.sizes);
process.stdout.write(`${done}/${manifest.icons.length} icons have the HQ style in ${out}\n`);
break;
}
default:
Expand Down
Loading
Loading