From fb6b2e36f602ca0ece1d2671860f02bff64ab975 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 11:59:07 +0000 Subject: [PATCH] icon: three agentic-era colour styles beside HQ The HQ style was drawn against glossy 3D emoji masters and reads as the decade it borrows from: specular hotspots, bevels, inflated bodies. This adds three flat-material styles that take no emoji reference at all, so the look comes from the prompt and the hue from one palette: agentic-matte flat tonal planes, no gloss - the general-purpose one agentic-machined milled anodized aluminium - hardware and rack UIs agentic-emissive dark body, lit working part - dark terminals only `agentic` alone means matte: it is the only one of the three that holds its colour coding at 20px on a light ground as well as a dark one, which is where the other two fall apart. The draw and derive engine is now style-aware rather than HQ-shaped, and a set carries every style at once: `styles` lists them, `style_info` describes each, and each icon has a `styles` map. HQ keeps writing the top-level `hq` block and per-icon `hq` key as the same objects under their older name, so a reader from the first HQ release does not break. Two things learned the hard way are now in code. A master is validated by decoding it, not by its existence: a run that fills the disk leaves a truncated file with a plausible mtime, and the mtime skip then carries it forward for good, which is how one HQ webp shipped broken. And drawing a style takes hours, so --draw-only pairs with --no-draw to draw several styles at once and derive them in one pass, keeping a single writer on openicon.json. Co-Authored-By: Claude Opus 5 (1M context) --- bin/icon.ts | 126 +++++++++++++++-------- src/icon-hq.ts | 239 ++++++++++++++++++++----------------------- src/icon-palette.ts | 94 +++++++++++++++++ src/icon-styles.ts | 146 ++++++++++++++++++++++++++ src/icon.ts | 103 +++++++++++++++---- test/icon-hq.test.ts | 68 +++++++++++- 6 files changed, 588 insertions(+), 188 deletions(-) create mode 100644 src/icon-palette.ts create mode 100644 src/icon-styles.ts diff --git a/bin/icon.ts b/bin/icon.ts index 77c804d..2d5af07 100755 --- a/bin/icon.ts +++ b/bin/icon.ts @@ -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: @@ -40,15 +41,30 @@ const USAGE = `Usage: icon show icon search icon glyph [--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 /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 @@ -83,22 +99,28 @@ 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 { +/** 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 { 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); @@ -106,7 +128,7 @@ if (isMain(import.meta.url)) { }); 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; @@ -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`, @@ -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`); @@ -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: diff --git a/src/icon-hq.ts b/src/icon-hq.ts index d7c5c5e..0927052 100644 --- a/src/icon-hq.ts +++ b/src/icon-hq.ts @@ -1,22 +1,23 @@ /** - * The HQ style of OpenIcon: every icon in full colour, as an alternative to - * the simple line set (which stays canonical and the default). + * The drawn styles of OpenIcon: every icon in full colour, as an alternative + * to the simple line set (which stays canonical and the default). This file is + * the engine; icon-styles.ts is what each style actually looks like. * - * UI icons are drawn by an image model at the OpenEmoji bar, and each draw is - * pinned two ways, which is what keeps 259 drawings coherent: + * Every draw is pinned by the icon's own simple line glyph, rendered at 1024 + * as the FIRST reference, with the prompt saying to keep its silhouette and + * meaning — that is what keeps 259 drawings coherent and keeps `filter` a + * funnel rather than whatever the model thinks filters are. The HQ style adds + * three OpenEmoji masters as style references so it reads as one family with + * the emoji; the agentic styles deliberately use none, because those masters + * are where the gloss comes from. * - * - the icon's own simple line glyph, rendered at 1024, is the FIRST - * reference, and the prompt says to keep its silhouette and meaning, so - * `filter` stays a funnel and not whatever the model thinks filters are; - * - three OpenEmoji masters follow as style references, so the icons read - * as the same family as the emoji. + * Brands are never drawn in any style: their coloured form is the same logo in + * the owner's published colour (Simple Icons' `hex`, or a cited brand page for + * the few that came from Font Awesome). * - * Brands are never drawn: their HQ form is the same logo in the owner's - * published colour (Simple Icons' `hex`, or a cited brand page for the few - * that came from Font Awesome). - * - * Masters live in hq/master (1024px, kept out of git); everything else is - * derived: hq/png/, hq/webp/, and hq/svg for brands. + * Masters live in /master (1024px, kept out of git); everything + * else is derived: /png/, /webp/, and + * /svg for brands. */ import { existsSync } from 'node:fs'; @@ -26,88 +27,20 @@ import { join } from 'node:path'; import { type ImageCaller, RefusedError, pool } from './emoji.ts'; import { BRANDS, SIMPLE_ICONS_VERSION } from './icon-brands.ts'; import { GENERIC, type IconDef } from './icon-set.ts'; -import { type Manifest, cached, humanName, strokeSvg } from './icon.ts'; +import { type Manifest, type StyleFiles, type StyleInfo, cached, strokeSvg } from './icon.ts'; +import { HQ, HQ_STYLE, STYLE_REFS, type StyleSpec } from './icon-styles.ts'; export const HQ_MODEL = 'gpt-image-2'; export const HQ_WEBP_SIZES = [64, 128] as const; export const HQ_CONCURRENCY = 12; -/** - * OpenEmoji masters the icons take their look from: laptop, gem, light bulb. - * Chosen for range: fire and rocket as references tinted every icon orange. - */ -export const STYLE_REFS = ['1f4bb.png', '1f48e.png', '1f4a1.png'] as const; /** Drawn first, to judge the prompt on a contact sheet before the rest. */ export const HQ_ANCHORS = ['mail', 'settings', 'search', 'delete', 'lock', 'calendar', 'cart', 'terminal', 'bell', 'folder'] as const; -export const HQ_STYLE = `Design one icon for a premium, original colour icon set that sits beside a glossy 3D emoji set as one family. -The FIRST reference image is this icon's line drawing: keep its exact silhouette, parts and meaning, and turn it into a solid, full-colour object. Where the line drawing outlines a shape (a triangle, a funnel, a square, a pin, a basket), fill that shape: a solid glass or enamel body, never a hollow tube tracing the outline. Only pure strokes (arrows, bars, plus and minus signs, chevrons) stay as rounded solid bars. Do not add or remove parts, do not change what it depicts, do not add text. -The OTHER reference images are the style to match exactly: soft-volume 3D with vector clarity, smooth rich gradients that model the form, one warm key light from the upper left with a crisp specular highlight, gentle ambient occlusion, a subtle darker rim on the lower-right edge, saturated harmonious colour. Do not copy their subjects. -Use the colour named below as the dominant colour, with small natural accents (white paper, steel, glass, gold) only where the object has them. Ignore the reference images' colours. -Composition: the single object, centred, filling about 84% of the square, front or slight three-quarter view, nothing cropped. Fully transparent background, no ground shadow, no badge, no frame, no backdrop. -It must stay instantly readable at 20 pixels. Every design is original: never resemble a logo, mascot or product from any brand, film or game.`; - -/** - * Colour is decided here, not by the model: left to itself it drifts to one - * hue for the whole set (orange with warm references, blue with cool ones). - * Each category has its own colour, so a page of icons reads as grouped, and - * meaning overrides the category where colour carries meaning. - */ -export const CATEGORY_COLORS: Record = { - action: 'deep indigo-violet (#5B4BDB) glass', - navigation: 'teal (#0FA3A3) glass', - communication: 'sky blue (#1E88E5) glass', - media: 'magenta-pink (#E0337A) glass', - file: 'warm amber-yellow (#F5B422) with white paper', - status: 'cobalt blue (#2F6FEB) glass', - time: 'cyan (#12A8C9) glass with white faces', - commerce: 'emerald green (#1FA35C) glass', - dev: 'graphite slate (#39424E) with electric lime (#9BE22E) accents', - device: 'brushed silver aluminium with dark glass screens and a blue glow', - editor: 'violet-purple (#8E44D9) glass', - misc: "the object's own natural colours", -}; - -const OVERRIDES: Array<[string[], string]> = [ - [['add', 'plus-circle', 'check', 'check-circle', 'checkbox', 'toggle-on', 'online', 'user-plus', 'user-check', 'shield-check', 'calendar-check', 'clipboard-check', 'download', 'cloud-download', 'folder-plus', 'file-plus', 'calendar-plus', 'battery-charging'], 'fresh green (#22B35A) glass'], - [['delete', 'close', 'x-circle', 'error', 'ban', 'minus-circle', 'user-minus', 'phone-off', 'mic-off', 'bell-off', 'volume-off', 'wifi-off', 'unlink', 'bug', 'power'], 'coral red (#E5484D) glass'], - [['warning', 'bell', 'alarm', 'star', 'key', 'lightbulb', 'zap', 'award', 'trophy', 'sun', 'megaphone', 'coins', 'dollar'], 'warm gold-amber (#F5A524) glass'], - [['heart'], 'glossy red (#E0245E)'], - [['lock', 'unlock', 'shield', 'fingerprint'], 'steel blue-grey (#5B6B82) metal with a gold keyhole or accent'], - [['sparkles', 'palette', 'brush', 'pen-tool', 'theme'], 'purple-to-magenta (#8E44D9 to #E0337A) glass'], - [['info', 'help'], 'cobalt blue (#2F6FEB) glass'], - [['moon'], 'midnight indigo (#3949AB) with a pale gold rim'], - [['leaf'], 'leaf green (#3BAA35)'], - [['droplet'], 'clear water blue (#2AA7F0)'], - [['coffee'], 'white ceramic with a coffee-brown (#6F4E37) interior'], - [['rocket'], 'white and silver with a teal window and a small orange flame'], - [['thermometer'], 'glass with a red (#E5484D) mercury bulb'], - [['umbrella', 'anchor', 'building', 'briefcase'], 'navy (#24407A) with brass accents'], - [['rss'], 'orange (#F57C00) glass'], -]; - -export function hqColorFor(icon: IconDef): string { - for (const [keys, color] of OVERRIDES) if (keys.includes(icon.key)) return color; - return CATEGORY_COLORS[icon.category] ?? CATEGORY_COLORS.misc!; -} +export { HQ_STYLE, STYLE_REFS }; +export { CATEGORY_COLORS, hqColorFor } from './icon-palette.ts'; -/** Keys whose names mislead a model: say what the control is. */ -const DESCRIBE: Record = { - 'radio-off': 'an empty, unselected radio button: a single ring with nothing inside (a form control, not a radio set)', - 'radio-on': 'a selected radio button: a ring with a solid dot in the middle (a form control)', - 'checkbox-empty': 'an empty, unchecked checkbox (a form control)', - checkbox: 'a checked checkbox (a form control)', - 'toggle-on': 'a toggle switch in the on position (a form control)', - 'toggle-off': 'a toggle switch in the off position (a form control)', - online: 'an online presence indicator: a solid dot inside a ring', -}; - -export function hqPromptFor(icon: IconDef): string { - if (DESCRIBE[icon.key]) { - return `${HQ_STYLE}\n\nThe icon: "${icon.key}", which is ${DESCRIBE[icon.key]}.\nColour: ${hqColorFor(icon)}.`; - } - const words = [humanName(icon.key).toLowerCase(), ...(icon.aliases ?? []), ...(icon.keywords ?? [])].slice(0, 6); - return `${HQ_STYLE}\n\nThe icon: "${icon.key}" (${words.join(', ')}), category ${icon.category}.\nColour: ${hqColorFor(icon)}.`; -} +/** The HQ style's prompt for one icon. Other styles: `STYLES[id].promptFor`. */ +export const hqPromptFor = (icon: IconDef): string => HQ.promptFor(icon); /** * Brand colours for the logos that came from Font Awesome, which carries no @@ -149,6 +82,7 @@ export interface HqDrawOptions { out: string; keys: string[]; caller: ImageCaller; + /** Where the OpenEmoji masters live; unread by a style with no emoji refs. */ styleDir: string; concurrency: number; quality: string; @@ -156,6 +90,8 @@ export interface HqDrawOptions { log: (line: string) => void; render: (svg: string, size: number) => Promise | Buffer; sleep?: (ms: number) => Promise; + /** Which style to draw; the HQ style when a caller does not say. */ + style?: StyleSpec; } export interface HqDrawReport { @@ -166,11 +102,12 @@ export interface HqDrawReport { stoppedForCredit?: string; } -/** Draw the missing HQ masters; stops cleanly, keeping what landed, if credit runs out. */ +/** Draw a style's missing masters; stops cleanly, keeping what landed, if credit runs out. */ export async function drawHq(options: HqDrawOptions): Promise { - const master = join(options.out, 'hq', 'master'); + const style = options.style ?? HQ; + const master = join(options.out, style.dir, 'master'); await mkdir(master, { recursive: true }); - const styleRefs = await Promise.all(STYLE_REFS.map((f) => readFile(join(options.styleDir, f)))); + const styleRefs = await Promise.all(style.emojiRefs.map((f) => readFile(join(options.styleDir, f)))); const report: HqDrawReport = { drawn: [], skipped: [], failed: [], calls: 0 }; const byKey = new Map(GENERIC.map((i) => [i.key, i])); const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); @@ -198,14 +135,14 @@ export async function drawHq(options: HqDrawOptions): Promise { report.calls += 1; const result = await options.caller({ model: HQ_MODEL, - prompt: hqPromptFor(icon), + prompt: style.promptFor(icon), quality: options.quality, references: [line, ...styleRefs], }); await writeFile(join(master, `${key}.png`), result.png); await writeFile( - join(options.out, 'hq', 'prompts.jsonl'), - `${JSON.stringify({ key, prompt: hqPromptFor(icon), references: ['line', ...STYLE_REFS] })}\n`, + join(options.out, style.dir, 'prompts.jsonl'), + `${JSON.stringify({ key, style: style.id, prompt: style.promptFor(icon), references: ['line', ...style.emojiRefs] })}\n`, { flag: 'a' }, ); report.drawn.push(key); @@ -244,20 +181,31 @@ export interface HqBuildOptions { render: (svg: string, size: number) => Promise | Buffer; /** Simple Icons slug -> hex, injectable for tests. */ simpleIconsHex?: Map; + /** Which style to derive; the HQ style when a caller does not say. */ + style?: StyleSpec; } -export interface HqEntry { - png: string; - webp: string; - svg?: string; - made_by: 'ai' | 'human'; - hex?: string; - hex_source?: string; +export type HqEntry = StyleFiles; + +/** + * Whether an image file is really an image. A run that fills the disk leaves a + * truncated file behind with a plausible mtime, and a skip that trusts the + * mtime then carries that file forward for good: one HQ webp shipped that way. + * Decoding is the only check that catches it, so every skip pays for one. + */ +async function decodes(sharp: Sharp, file: string): Promise { + try { + const meta = await sharp(await readFile(file)).metadata(); + return Boolean(meta.width && meta.height); + } catch { + return false; + } } -async function newer(derived: string, source: string): Promise { +async function newer(sharp: Sharp, derived: string, source: string): Promise { if (!existsSync(derived)) return false; - return (await stat(derived)).mtimeMs >= (await stat(source)).mtimeMs; + if ((await stat(derived)).mtimeMs < (await stat(source)).mtimeMs) return false; + return decodes(sharp, derived); } async function loadSimpleIconsHex(fetchImpl: typeof fetch): Promise> { @@ -269,19 +217,21 @@ async function loadSimpleIconsHex(fetchImpl: typeof fetch): Promise> { const sharp = ((await import('sharp')) as unknown as { default: Sharp }).default; - const hq = join(options.out, 'hq'); + const style = options.style ?? HQ; + const hq = join(options.out, style.dir); const entries = new Map(); for (const size of options.sizes) await mkdir(join(hq, 'png', String(size)), { recursive: true }); for (const size of HQ_WEBP_SIZES) await mkdir(join(hq, 'webp', String(size)), { recursive: true }); await mkdir(join(hq, 'svg'), { recursive: true }); const files = (key: string): HqEntry => ({ - png: `hq/png/{size}/${key}.png`, - webp: `hq/webp/{size}/${key}.webp`, + png: `${style.dir}/png/{size}/${key}.png`, + webp: `${style.dir}/webp/{size}/${key}.webp`, made_by: 'ai', }); // UI icons: from the masters, trimmed and centred to one optical size. + const broken: string[] = []; const masterDir = join(hq, 'master'); const masters = existsSync(masterDir) ? (await readdir(masterDir)).filter((f) => f.endsWith('.png')) : []; const generic = new Set(GENERIC.map((i) => i.key)); @@ -293,7 +243,14 @@ export async function buildHq(options: HqBuildOptions): Promise join(hq, 'png', String(s), `${key}.png`)), ...HQ_WEBP_SIZES.map((s) => join(hq, 'webp', String(s), `${key}.webp`)), ]; - if (!(await Promise.all(outputs.map((o) => newer(o, source)))).every(Boolean)) { + if (!(await decodes(sharp, source))) { + // A master that will not decode is a failed or truncated draw: say so + // rather than deriving eight broken sizes from it. `--force` redraws it. + broken.push(key); + options.log(`BROKEN master ${style.id}/${key}: it does not decode; redraw it with --force --only ${key}`); + return; + } + if (!(await Promise.all(outputs.map((o) => newer(sharp, o, source)))).every(Boolean)) { const trimmed = await sharp(await readFile(source)).trim({ threshold: 1 }).toBuffer(); const meta = await sharp(trimmed).metadata(); const w = meta.width ?? 1024; @@ -349,30 +306,60 @@ export async function buildHq(options: HqBuildOptions): Promise e.made_by === 'ai').length} drawn, ${[...entries.values()].filter((e) => e.hex).length} brand-coloured)`); + options.log(`${entries.size} ${style.id} icons (${[...entries.values()].filter((e) => e.made_by === 'ai').length} drawn, ${[...entries.values()].filter((e) => e.hex).length} brand-coloured)`); + if (broken.length) options.log(`${broken.length} unreadable masters, left out of the manifest: ${broken.join(', ')}`); return entries; } -/** Add the `hq` block to an existing openicon.json, in place. */ -export function withHq(manifest: Manifest, entries: Map, sizes: number[]): Manifest { +/** + * Write one style into an existing openicon.json, leaving every other style + * alone: a set can carry `hq` and the three agentic styles at once, and each + * is built by its own run. + * + * `simple` leads the style list because it stays canonical; the rest follow in + * the order they were added. The HQ style also keeps writing the top-level + * `hq` block and the per-icon `hq` key it shipped with, so a reader written + * against the first HQ release does not break on a set that has four styles. + */ +export function withStyle(manifest: Manifest, style: StyleSpec, entries: Map, sizes: number[]): Manifest { + const info: StyleInfo = { + dir: style.dir, + label: style.label, + material: style.material, + sizes, + webp_sizes: [...HQ_WEBP_SIZES], + made_by: 'both', + ai_model: HQ_MODEL, + ai_provider: 'OpenAI', + ai_prompt_url: `${style.dir}/style.txt`, + coverage: { total: manifest.icons.length, done: manifest.icons.filter((i) => entries.has(i.key)).length }, + }; + const styles = [...new Set(['simple', ...(manifest.styles ?? []), style.id])]; return { ...manifest, - styles: ['simple', 'hq'], - hq: { - sizes, - webp_sizes: [...HQ_WEBP_SIZES], - made_by: 'both', - ai_model: HQ_MODEL, - ai_provider: 'OpenAI', - ai_prompt_url: 'hq/style.txt', - coverage: { total: manifest.icons.length, done: manifest.icons.filter((i) => entries.has(i.key)).length }, - }, - icons: manifest.icons.map((i) => { - const hq = entries.get(i.key); - const { hq: _old, ...rest } = i as typeof i & { hq?: HqEntry }; - return hq ? { ...rest, hq } : rest; + styles, + style_info: { ...manifest.style_info, [style.id]: info }, + ...(style.id === 'hq' ? { hq: info } : {}), + icons: manifest.icons.map((icon) => { + const entry = entries.get(icon.key); + const styled: Record = { ...icon.styles }; + if (entry) styled[style.id] = entry; + else delete styled[style.id]; + const next: typeof icon = { ...icon }; + if (Object.keys(styled).length) next.styles = styled; + else delete next.styles; + if (style.id === 'hq') { + if (entry) next.hq = entry; + else delete next.hq; + } + return next; }), }; } + +/** The HQ style, written the way the first HQ release wrote it. */ +export function withHq(manifest: Manifest, entries: Map, sizes: number[]): Manifest { + return withStyle(manifest, HQ, entries, sizes); +} diff --git a/src/icon-palette.ts b/src/icon-palette.ts new file mode 100644 index 0000000..a7a0296 --- /dev/null +++ b/src/icon-palette.ts @@ -0,0 +1,94 @@ +/** + * Which hue every icon gets, in one place, because the model will not decide + * it well: left to itself it drifts the whole set to a single hue (orange with + * warm references, blue with cool ones). Each category has its own colour, so + * a page of icons reads as grouped, and meaning overrides the category where + * colour carries meaning (delete is red whatever category it sits in). + * + * Each entry carries the hue twice, because the two style families describe + * material differently and a prompt is read literally: + * + * - `hq` is the glossy vocabulary the shipped HQ style was drawn with + * ("deep indigo-violet (#5B4BDB) glass") and is kept verbatim, so nothing + * already drawn would come back different if it were drawn again; + * - `accent` is the same hue named plainly ("indigo-violet #5B4BDB"), which + * the agentic styles put into their own material sentence. + */ + +import type { IconDef } from './icon-set.ts'; + +export interface Hue { + /** The HQ style's phrasing, glass and enamel. */ + hq: string; + /** The hue alone, for a style that supplies its own material. */ + accent: string; +} + +export const CATEGORY_PALETTE: Record = { + action: { hq: 'deep indigo-violet (#5B4BDB) glass', accent: 'indigo-violet #5B4BDB' }, + navigation: { hq: 'teal (#0FA3A3) glass', accent: 'teal #0FA3A3' }, + communication: { hq: 'sky blue (#1E88E5) glass', accent: 'sky blue #1E88E5' }, + media: { hq: 'magenta-pink (#E0337A) glass', accent: 'magenta-pink #E0337A' }, + file: { hq: 'warm amber-yellow (#F5B422) with white paper', accent: 'warm amber-yellow #F5B422, with white paper where the object has paper' }, + status: { hq: 'cobalt blue (#2F6FEB) glass', accent: 'cobalt blue #2F6FEB' }, + time: { hq: 'cyan (#12A8C9) glass with white faces', accent: 'cyan #12A8C9, with a pale face where the object has one' }, + commerce: { hq: 'emerald green (#1FA35C) glass', accent: 'emerald green #1FA35C' }, + dev: { hq: 'graphite slate (#39424E) with electric lime (#9BE22E) accents', accent: 'electric lime #9BE22E on graphite #39424E' }, + device: { hq: 'brushed silver aluminium with dark glass screens and a blue glow', accent: 'silver-grey #B4BAC2 with a blue #2F6FEB screen' }, + editor: { hq: 'violet-purple (#8E44D9) glass', accent: 'violet-purple #8E44D9' }, + misc: { hq: "the object's own natural colours", accent: "the object's own natural colours" }, +}; + +/** Meaning beats category: these keys take their colour from what they mean. */ +export const OVERRIDES: Array<{ keys: string[]; hue: Hue }> = [ + { + keys: ['add', 'plus-circle', 'check', 'check-circle', 'checkbox', 'toggle-on', 'online', 'user-plus', 'user-check', 'shield-check', 'calendar-check', 'clipboard-check', 'download', 'cloud-download', 'folder-plus', 'file-plus', 'calendar-plus', 'battery-charging'], + hue: { hq: 'fresh green (#22B35A) glass', accent: 'fresh green #22B35A' }, + }, + { + keys: ['delete', 'close', 'x-circle', 'error', 'ban', 'minus-circle', 'user-minus', 'phone-off', 'mic-off', 'bell-off', 'volume-off', 'wifi-off', 'unlink', 'bug', 'power'], + hue: { hq: 'coral red (#E5484D) glass', accent: 'coral red #E5484D' }, + }, + { + keys: ['warning', 'bell', 'alarm', 'star', 'key', 'lightbulb', 'zap', 'award', 'trophy', 'sun', 'megaphone', 'coins', 'dollar'], + hue: { hq: 'warm gold-amber (#F5A524) glass', accent: 'warm gold-amber #F5A524' }, + }, + { keys: ['heart'], hue: { hq: 'glossy red (#E0245E)', accent: 'red #E0245E' } }, + { + keys: ['lock', 'unlock', 'shield', 'fingerprint'], + hue: { hq: 'steel blue-grey (#5B6B82) metal with a gold keyhole or accent', accent: 'steel blue-grey #5B6B82, with a gold #F5A524 keyhole or accent' }, + }, + { + keys: ['sparkles', 'palette', 'brush', 'pen-tool', 'theme'], + hue: { hq: 'purple-to-magenta (#8E44D9 to #E0337A) glass', accent: 'purple #8E44D9 shading to magenta #E0337A' }, + }, + { keys: ['info', 'help'], hue: { hq: 'cobalt blue (#2F6FEB) glass', accent: 'cobalt blue #2F6FEB' } }, + { keys: ['moon'], hue: { hq: 'midnight indigo (#3949AB) with a pale gold rim', accent: 'midnight indigo #3949AB with a pale gold rim' } }, + { keys: ['leaf'], hue: { hq: 'leaf green (#3BAA35)', accent: 'leaf green #3BAA35' } }, + { keys: ['droplet'], hue: { hq: 'clear water blue (#2AA7F0)', accent: 'water blue #2AA7F0' } }, + { keys: ['coffee'], hue: { hq: 'white ceramic with a coffee-brown (#6F4E37) interior', accent: 'off-white #EDEDE8 with a coffee-brown #6F4E37 interior' } }, + { keys: ['rocket'], hue: { hq: 'white and silver with a teal window and a small orange flame', accent: 'off-white and silver with a teal #0FA3A3 window and a small orange #F57C00 flame' } }, + { keys: ['thermometer'], hue: { hq: 'glass with a red (#E5484D) mercury bulb', accent: 'pale grey with a red #E5484D bulb' } }, + { keys: ['umbrella', 'anchor', 'building', 'briefcase'], hue: { hq: 'navy (#24407A) with brass accents', accent: 'navy #24407A with brass #B08D45 accents' } }, + { keys: ['rss'], hue: { hq: 'orange (#F57C00) glass', accent: 'orange #F57C00' } }, +]; + +export function hueFor(icon: IconDef): Hue { + for (const { keys, hue } of OVERRIDES) if (keys.includes(icon.key)) return hue; + return CATEGORY_PALETTE[icon.category] ?? CATEGORY_PALETTE.misc!; +} + +/** The HQ style's colour sentence for an icon. */ +export function hqColorFor(icon: IconDef): string { + return hueFor(icon).hq; +} + +/** The hue alone, for a style that describes its own material. */ +export function accentFor(icon: IconDef): string { + return hueFor(icon).accent; +} + +/** Kept as it was published: the HQ phrasing, by category. */ +export const CATEGORY_COLORS: Record = Object.fromEntries( + Object.entries(CATEGORY_PALETTE).map(([category, hue]) => [category, hue.hq]), +); diff --git a/src/icon-styles.ts b/src/icon-styles.ts new file mode 100644 index 0000000..bdf178b --- /dev/null +++ b/src/icon-styles.ts @@ -0,0 +1,146 @@ +/** + * The styles OpenIcon ships beside the canonical `simple` line set. + * + * `hq` came first and was drawn against glossy 3D emoji masters, which is + * exactly what it looks like: specular hotspots, bevels, inflated bodies. It + * stays, because plenty of surfaces want it, but it dates the set. + * + * The three `agentic-*` styles are the answer to that. They share one frame + * (same composition, same silhouette pin, same 20px rule) and differ only in + * the material paragraph, so the three can be compared honestly and a reader + * can pick by surface rather than by taste: + * + * agentic-matte flat tonal planes, no gloss — the general-purpose one + * agentic-machined milled anodized aluminium — hardware and rack UIs + * agentic-emissive dark body, lit working part — dark terminals only + * + * `agentic` on its own means `agentic-matte`: it is the only one of the three + * that holds its colour coding at 20 pixels on a light ground as well as a + * dark one, so it is the safe default for anything that is not known to be + * dark-only. + * + * None of the three takes an emoji master as a reference. Their only reference + * is the icon's own line glyph, which is what keeps `filter` a funnel; the + * look comes from the prompt alone, and the hue from icon-palette.ts. + */ + +import { accentFor, hqColorFor } from './icon-palette.ts'; +import type { IconDef } from './icon-set.ts'; +import { humanName } from './icon.ts'; + +/** Keys whose names mislead a model: say what the control is. */ +export const DESCRIBE: Record = { + 'radio-off': 'an empty, unselected radio button: a single ring with nothing inside (a form control, not a radio set)', + 'radio-on': 'a selected radio button: a ring with a solid dot in the middle (a form control)', + 'checkbox-empty': 'an empty, unchecked checkbox (a form control)', + checkbox: 'a checked checkbox (a form control)', + 'toggle-on': 'a toggle switch in the on position (a form control)', + 'toggle-off': 'a toggle switch in the off position (a form control)', + online: 'an online presence indicator: a solid dot inside a ring', +}; + +/** How an icon is named to the model: what it is, then what it is called. */ +export function subjectLine(icon: IconDef): string { + if (DESCRIBE[icon.key]) return `The icon: "${icon.key}", which is ${DESCRIBE[icon.key]}.`; + const words = [humanName(icon.key).toLowerCase(), ...(icon.aliases ?? []), ...(icon.keywords ?? [])].slice(0, 6); + return `The icon: "${icon.key}" (${words.join(', ')}), category ${icon.category}.`; +} + +export interface StyleSpec { + id: string; + label: string; + /** Where this style's files live inside the set. */ + dir: string; + /** One line, for the manifest, the README and the set's index page. */ + material: string; + /** OpenEmoji masters used as style references; the agentic styles use none. */ + emojiRefs: readonly string[]; + /** The style paragraph, without the icon. */ + styleText: string; + promptFor(icon: IconDef): string; +} + +export const HQ_STYLE = `Design one icon for a premium, original colour icon set that sits beside a glossy 3D emoji set as one family. +The FIRST reference image is this icon's line drawing: keep its exact silhouette, parts and meaning, and turn it into a solid, full-colour object. Where the line drawing outlines a shape (a triangle, a funnel, a square, a pin, a basket), fill that shape: a solid glass or enamel body, never a hollow tube tracing the outline. Only pure strokes (arrows, bars, plus and minus signs, chevrons) stay as rounded solid bars. Do not add or remove parts, do not change what it depicts, do not add text. +The OTHER reference images are the style to match exactly: soft-volume 3D with vector clarity, smooth rich gradients that model the form, one warm key light from the upper left with a crisp specular highlight, gentle ambient occlusion, a subtle darker rim on the lower-right edge, saturated harmonious colour. Do not copy their subjects. +Use the colour named below as the dominant colour, with small natural accents (white paper, steel, glass, gold) only where the object has them. Ignore the reference images' colours. +Composition: the single object, centred, filling about 84% of the square, front or slight three-quarter view, nothing cropped. Fully transparent background, no ground shadow, no badge, no frame, no backdrop. +It must stay instantly readable at 20 pixels. Every design is original: never resemble a logo, mascot or product from any brand, film or game.`; + +/** + * OpenEmoji masters the HQ icons take their look from: laptop, gem, light bulb. + * Chosen for range: fire and rocket as references tinted every icon orange. + */ +export const STYLE_REFS = ['1f4bb.png', '1f48e.png', '1f4a1.png'] as const; + +/** Shared by the agentic styles: everything except the material. */ +export const AGENTIC_FRAME = `Design one icon for a premium, original icon set made for developer and agent tooling. +The reference image is this icon's line drawing: keep its exact silhouette, parts and meaning, and turn it into a solid object. Where the line drawing outlines a shape (a funnel, a square, a pin, a basket), fill that shape as a solid body, never a hollow tube tracing the outline. Only pure strokes (arrows, bars, plus and minus signs, chevrons) stay as rounded solid bars. Do not add or remove parts, do not change what it depicts, do not add text. +Composition: the single object, centred, filling about 84% of the square, straight-on front view, nothing cropped. Fully transparent background, no ground shadow, no badge, no frame, no backdrop. +It must stay instantly readable at 20 pixels. Every design is original: never resemble a logo, mascot or product from any brand, film or game.`; + +const AGENTIC_MATERIALS: Record = { + matte: { + label: 'Agentic Matte', + material: 'Flat tonal planes with hard boundaries and one cool edge-light: form without gloss.', + paragraph: `Style: matte flat-dimensional. Build the form from two or three FLAT tonal planes of one hue (a lit face, a shaded side, a darker base) with hard, clean boundaries between them. Absolutely no gloss: no specular highlight, no white hotspot, no shiny plastic, no glassy sheen, no bevel, no inflated 3D bubble, no soft blended gradient. Crisp geometric vector edges, generous corner radii, one thin cooler edge-light along the top-left edge only. Restrained, modern, engineered.`, + }, + machined: { + label: 'Agentic Machined', + material: 'Milled anodized aluminium: brushed texture, hairline chamfers, one anodized accent.', + paragraph: `Style: precision-machined hardware. The object looks milled from matte anodized aluminium: very fine brushed texture, hairline chamfers where faces meet, a dark graphite body with one anodized accent colour on the working part. Lighting is soft, even and diffuse from directly above. No specular highlight, no glossy sheen, no shiny plastic, no glassy bubble, no soft blended gradient, no cartoon bevel. Tight tolerances, technical, industrial-grade.`, + }, + emissive: { + label: 'Agentic Emissive', + material: 'Near-black body with the working part lit: made for dark terminals, not light pages.', + paragraph: `Style: emissive dark instrument. A dark matte graphite body, almost black, with the working part of the icon rendered as a clean luminous emissive accent in the colour below: a lit line, edge or panel that glows softly into the surrounding body. No gloss, no specular highlight, no shiny plastic, no glassy bubble, no soft blended gradient over the whole form; the only light in the image is the emissive accent. Crisp vector edges. It reads like lit hardware in a dark rack.`, + }, +}; + +function agenticStyle(variant: keyof typeof AGENTIC_MATERIALS): StyleSpec { + const { label, material, paragraph } = AGENTIC_MATERIALS[variant]!; + const id = `agentic-${variant}`; + return { + id, + label, + dir: `styles/${id}`, + material, + emojiRefs: [], + styleText: `${AGENTIC_FRAME}\n${paragraph}`, + promptFor(icon) { + return `${AGENTIC_FRAME}\n${paragraph}\nAccent colour: ${accentFor(icon)}. Use it as the dominant or accent colour as the style dictates.\n\n${subjectLine(icon)}`; + }, + }; +} + +export const HQ: StyleSpec = { + id: 'hq', + label: 'HQ', + dir: 'hq', + material: 'Glass and enamel bodies under a warm key light, drawn beside the OpenEmoji masters.', + emojiRefs: STYLE_REFS, + styleText: HQ_STYLE, + promptFor(icon) { + return `${HQ_STYLE}\n\n${subjectLine(icon)}\nColour: ${hqColorFor(icon)}.`; + }, +}; + +export const STYLES: Record = { + hq: HQ, + 'agentic-matte': agenticStyle('matte'), + 'agentic-machined': agenticStyle('machined'), + 'agentic-emissive': agenticStyle('emissive'), +}; + +/** The agentic variant a bare `agentic` means: the one that holds up everywhere. */ +export const AGENTIC_DEFAULT = 'agentic-matte'; + +export const AGENTIC_STYLES = ['agentic-matte', 'agentic-machined', 'agentic-emissive'] as const; + +/** `agentic` is the family's default; everything else is an exact id. */ +export function resolveStyle(name: string): StyleSpec { + const id = name === 'agentic' ? AGENTIC_DEFAULT : name; + const style = STYLES[id]; + if (!style) throw new Error(`unknown style: ${name} (styles: ${Object.keys(STYLES).join(', ')}, or agentic)`); + return style; +} diff --git a/src/icon.ts b/src/icon.ts index 0ccafeb..95281e1 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -82,14 +82,23 @@ export interface IconEntry { png: string; tui: Glyphs; /** The HQ style, when the set has one: see icon-hq.ts. */ - hq?: { - png: string; - webp: string; - svg?: string; - made_by: 'ai' | 'human'; - hex?: string; - hex_source?: string; - }; + hq?: StyleFiles; + /** + * Every drawn style this icon has, keyed by style id (`hq`, `agentic-matte`, + * …). `hq` above is the same entry under its old name, kept so readers + * written against the first HQ release keep working. + */ + styles?: Record; +} + +/** Where one icon's files live in one style. `{size}` is the caller's to fill. */ +export interface StyleFiles { + png: string; + webp: string; + svg?: string; + made_by: 'ai' | 'human'; + hex?: string; + hex_source?: string; } export interface Manifest { @@ -110,18 +119,29 @@ export interface Manifest { categories: Record; /** Present when the set ships more than the simple style. */ styles?: string[]; - hq?: { - sizes: number[]; - webp_sizes: number[]; - made_by: 'ai' | 'human' | 'both'; - ai_model: string; - ai_provider: string; - ai_prompt_url: string; - coverage: { total: number; done: number }; - }; + /** One block per drawn style in `styles`, keyed by style id. */ + style_info?: Record; + /** The first HQ release's block, kept under its old name beside style_info.hq. */ + hq?: StyleInfo; icons: IconEntry[]; } +/** What a reader needs to know about one drawn style before using it. */ +export interface StyleInfo { + /** Where the style's files sit in the set; absent on the first HQ block. */ + dir?: string; + label?: string; + /** One line on the material, so a reader can choose without looking. */ + material?: string; + sizes: number[]; + webp_sizes: number[]; + made_by: 'ai' | 'human' | 'both'; + ai_model: string; + ai_provider: string; + ai_prompt_url: string; + coverage: { total: number; done: number }; +} + // ------------------------------------------------------------------ names /** "git-pull-request" -> "Git pull request". */ @@ -340,7 +360,21 @@ async function resvgRenderer(): Promise<(svg: string, size: number) => Buffer> { return (svg, size) => Buffer.from(new Resvg(svg, { fitTo: { mode: 'width', value: size } }).render().asPng()); } +/** An icon's colour styles, with `hq` under both its names counted once. */ +export function stylesOf(icon: IconEntry): Record { + return { ...(icon.hq ? { hq: icon.hq } : {}), ...icon.styles }; +} + export function previewFor(manifest: Manifest, svgs: Map): string { + // The colour styles the set has actually derived files for, in its own order. + const colour = (manifest.styles ?? []).filter((id) => id !== 'simple' && manifest.icons.some((i) => stylesOf(i)[id])); + const artFor = (icon: IconEntry, id: string): string => { + const files = stylesOf(icon)[id]; + if (!files) return ''; + const src = files.svg ?? files.webp?.replace('{size}', '64') ?? files.png?.replace('{size}', '64') ?? ''; + return src ? `` : ''; + }; + const byCategory = new Map(); for (const icon of manifest.icons) byCategory.set(icon.category, [...(byCategory.get(icon.category) ?? []), icon]); const sections = [...byCategory] @@ -349,26 +383,55 @@ export function previewFor(manifest: Manifest, svgs: Map): strin
${icons .map( (i) => - `
${svgs.get(i.key)!.replace('${escapeXml(i.key)}${escapeXml(i.tui.unicode)} ${escapeXml(i.tui.ascii)}
`, + `
${svgs.get(i.key)!.replace(' artFor(i, id)).join('')}
${escapeXml(i.key)}
${escapeXml(i.tui.unicode)} ${escapeXml(i.tui.ascii)}
`, ) .join('')}
`, ) .join('\n'); + + // One button per style, and a hidden image per style that the CSS reveals: + // no script, and a browser never fetches the artwork of a style nobody picks. + const all = ['simple', ...colour]; + const styleBar = colour.length + ? `${all + .map((id) => ``) + .join('')} +
${all + .map((id) => { + const info = manifest.style_info?.[id]; + const label = id === 'simple' ? 'Simple' : (info?.label ?? id); + const title = id === 'simple' ? 'The line icon, in the text colour' : (info?.material ?? ''); + return ``; + }) + .join('')}
` + : ''; + const styleCss = colour.length + ? `input[name=style]{position:absolute;opacity:0;pointer-events:none} +.styles{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 20px} +.styles label{padding:6px 11px;border:1px solid var(--line);border-radius:999px;font-size:12px;color:var(--muted);cursor:pointer} +.art{display:none} +${all.map((id) => `#style-${id}:checked~.styles label[for="style-${id}"]`).join(',')}{background:var(--ink);border-color:var(--ink);color:var(--bg)} +${all.map((id) => `#style-${id}:checked~.sheet .art[data-style="${id}"]`).join(',')}{display:block;margin:0 auto}` + : '.art{display:block;margin:0 auto}'; return ` OpenIcon preview

OpenIcon

${manifest.icons.length} icons on a 24px grid, each with a Nerd Font, Unicode and ASCII glyph for terminals. OpenIcon ${manifest.openicon}.

+${styleBar} +
${sections} +
`; } diff --git a/test/icon-hq.test.ts b/test/icon-hq.test.ts index 4d2aa12..7792909 100644 --- a/test/icon-hq.test.ts +++ b/test/icon-hq.test.ts @@ -8,8 +8,9 @@ import { describe, expect, it } from 'vitest'; import type { ImageRequest } from '../src/emoji.ts'; import { BRANDS } from '../src/icon-brands.ts'; import { - CreditError, FA_BRAND_COLORS, STYLE_REFS, buildHq, drawHq, guardCredits, hqPromptFor, withHq, + CreditError, FA_BRAND_COLORS, STYLE_REFS, buildHq, drawHq, guardCredits, hqPromptFor, withHq, withStyle, } from '../src/icon-hq.ts'; +import { resolveStyle } from '../src/icon-styles.ts'; import { GENERIC } from '../src/icon-set.ts'; import type { Manifest } from '../src/icon.ts'; @@ -150,6 +151,71 @@ describe('withHq', () => { }); }); +describe('styles', () => { + it('agentic takes no emoji reference and names its own material', () => { + const mail = GENERIC.find((i) => i.key === 'mail')!; + const matte = resolveStyle('agentic'); + expect(matte.id).toBe('agentic-matte'); + expect(matte.emojiRefs).toEqual([]); + const prompt = matte.promptFor(mail); + expect(prompt).toContain('keep its exact silhouette'); + expect(prompt).toContain('FLAT tonal planes'); + expect(prompt).toContain('no specular highlight'); + expect(prompt).toContain('Accent colour: sky blue #1E88E5'); + // The gloss vocabulary belongs to the HQ style and must not leak in. + expect(prompt).not.toContain('glossy 3D emoji'); + expect(resolveStyle('hq').promptFor(mail)).toContain('glossy 3D emoji'); + }); + + it('each agentic style is drawn and derived under its own directory', async () => { + const { out, styleDir } = await setup(); + const style = resolveStyle('agentic-emissive'); + await drawHq({ ...base(out, styleDir, async () => ({ png: PNG, tokens: 1 })), style, keys: ['mail'] }); + expect(existsSync(join(out, 'styles', 'agentic-emissive', 'master', 'mail.png'))).toBe(true); + expect(existsSync(join(out, 'hq', 'master', 'mail.png'))).toBe(false); + }); + + it('an unreadable master is left out rather than derived into broken sizes', async () => { + const { out } = await setup(); + const style = resolveStyle('agentic-matte'); + await mkdir(join(out, style.dir, 'master'), { recursive: true }); + await writeFile(join(out, style.dir, 'master', 'mail.png'), await sharpSquare()); + // What a run that filled the disk leaves behind: a plausible file that is not an image. + await writeFile(join(out, style.dir, 'master', 'phone.png'), Buffer.alloc(512)); + + const lines: string[] = []; + const entries = await buildHq({ + out, sizes: [16], style, log: (l) => lines.push(l), render: () => PNG, simpleIconsHex: new Map(), + }); + expect(entries.has('mail')).toBe(true); + expect(entries.has('phone')).toBe(false); + expect(lines.join('\n')).toContain('BROKEN master agentic-matte/phone'); + }); +}); + +describe('withStyle', () => { + it('keeps every style in one manifest, and hq keeps its old name too', () => { + const manifest = { + openicon: '0.1', sizes: [16], icons: [{ key: 'mail', svg: 'svg/mail.svg', png: 'png/{size}/mail.png' }], + } as unknown as Manifest; + const entry = (dir: string) => new Map([['mail', { png: `${dir}/png/{size}/mail.png`, webp: `${dir}/webp/{size}/mail.webp`, made_by: 'ai' as const }]]); + + const withBoth = withStyle( + withHq(manifest, entry('hq'), [16]), + resolveStyle('agentic-matte'), + entry('styles/agentic-matte'), + [16], + ); + + expect(withBoth.styles).toEqual(['simple', 'hq', 'agentic-matte']); + expect(Object.keys(withBoth.style_info!)).toEqual(['hq', 'agentic-matte']); + expect(withBoth.style_info!['agentic-matte']).toMatchObject({ dir: 'styles/agentic-matte', label: 'Agentic Matte' }); + // Both names, one object: a reader from the first HQ release still works. + expect(withBoth.icons[0]!.hq).toEqual(withBoth.icons[0]!.styles!.hq); + expect(withBoth.icons[0]!.styles!['agentic-matte']!.png).toBe('styles/agentic-matte/png/{size}/mail.png'); + }); +}); + async function sharpSquare(): Promise { const sharp = (await import('sharp')).default; return sharp({ create: { width: 64, height: 64, channels: 4, background: { r: 200, g: 50, b: 50, alpha: 1 } } }).png().toBuffer();