@haruhimemoe/brand
Brand kit generator for the tools: palettes from one hue, Nunito wordmarks, monogram icons, README banners and Open Graph cards.
Version 0.8.0, MIT
On this page
The brand kit for haruhime.moe and its osu! tools (packs, pools, bb, sheets), generated from one table:
| Product | Mark | Hue |
|---|---|---|
| haruhime (the parent site, haruhime.moe) | h. | 333 (pink) |
| packs | pk. | 30 (orange) |
| pools | pl. | 200 (blue) |
| bb | bb. | 265 (violet) |
| sheets | sh. | 150 (green) |
- Palette from one hue: six backgrounds, four text colors and two highlights, as hex values (
palette,TOKENSand each product's<name>-palette.json). The palette alone is also a browser-safe entry,@haruhimemoe/brand/palette. This package ships no CSS. The same HSL recipe is in @haruhimemoe/ui'stheme.css, which haruhime.moe, packs.haruhime.moe and pools.haruhime.moe import: there, an app sets--hueand the CSS does the rest. - Wordmark: the name in Nunito ExtraBold plus a dot in the highlight color (the deeper
h2on light backgrounds, whereh1is too pale), outlined to SVG paths. - Stacked wordmark: a product with a
suffix(only haruhime, with.moe) drops the round dot. The suffix goes on a second line at half size, right-aligned to the end of the name, with its own dot in the highlight color and its letters in the text color. - Icon: the one- or two-letter mark plus the dot. Every product uses the same letter size, so the icons match as a family.
- Link preview: a 1200×630 image with the wordmark (stacked, for haruhime) over the tagline.
- README banner: a 1280×320 image with rounded corners for GitHub READMEs: the wordmark (stacked, for haruhime) centered over the tagline. One for dark pages, one for light.
All text is outlined, so the SVGs need no fonts, and the PNGs come out the same on every machine. Apps commit the generated files, and nothing renders per request.
Install
#bun add -d @haruhimemoe/brandWith npm: npm install --save-dev @haruhimemoe/brand. It needs Node 22.12 or later (see Compatibility). An app that uses @haruhimemoe/brand/palette or @haruhimemoe/brand/products at runtime (in a page, not a build script) installs it as a regular dependency instead.
CLI
#bunx haruhime-brand pools # writes pools' files into ./public and ./src/appbunx haruhime-brand pools --dry-run # prints the paths insteadbunx haruhime-brand listbunx haruhime-brand preview # writes preview/index.html showing every productWith npm, run it as npx haruhime-brand.
| Command | What it does |
|---|---|
haruhime-brand <product> | Writes the product's brand files into a Next.js app. <product> is haruhime, packs, pools, bb or sheets. |
haruhime-brand list | Prints one line per product: its key, mark, hue and URL, separated by tabs. |
haruhime-brand preview [--out <dir>] | Writes <dir>/index.html (default preview/index.html, relative to where you run it): one page with every product's wordmarks, icons, link preview, banners and palette. |
haruhime-brand help (or --help, -h) | Prints the usage. |
haruhime-brand --version | Prints haruhime-brand <version>. |
list prints:
haruhime h. hue 333 https://haruhime.moepacks pk. hue 30 https://packs.haruhime.moepools pl. hue 200 https://pools.haruhime.moebb bb. hue 265 https://bb.haruhime.moesheets sh. hue 150 https://sheets.haruhime.moepreview writes into the folder you run it from (preview/ by default). Add preview/ to your app's .gitignore, or pass --out with a folder outside the app.
Files it writes
#haruhime-brand <product> writes these, in this order (paths shown for an app with src/app; it uses app/ when that's what the app has):
| File | For |
|---|---|
public/brand/<name>-wordmark.svg | dark backgrounds (white text) |
public/brand/<name>-wordmark-on-light.svg | light backgrounds (dark text) |
public/brand/<name>-icon.svg | brand page, schema.org logo |
public/brand/<name>-banner.svg | README banner, dark background (1280×320) |
public/brand/<name>-banner-on-light.svg | README banner, white background |
public/brand/<name>-banner.png | the dark banner as a 1280×320 PNG, for places that don't show SVG |
public/brand/<name>-palette.json | { hue, colors } for a brand page |
src/app/icon.svg | favicon (Next.js serves it by name) |
src/app/apple-icon.png | 180×180, square corners (iOS rounds them) |
src/app/opengraph-image.png + .alt.txt | the 1200×630 link preview and its alt text |
The alt text is the full name and the tagline, like pools: osu! mappools for tournament hosts or haruhime.moe: osu! tools for players, mappers and hosts. The palette file looks like { "hue": 200, "colors": { "b1": "#5c6970", ... } }, with every token from palette.
For each file it prints wrote <path>, or replaced <path> when the file was already there, with absolute paths. Rerun it after upgrading this package and commit the changes.
Options
#haruhime-brand <product> takes:
| Option | Default | Meaning |
|---|---|---|
--root <dir> | . | The app's root folder, resolved from where you run the command. The other paths resolve against it. |
--public <dir> | public | The app's static folder. It must resolve inside --root, symlinks followed (see below). |
--app <dir> | src/app if it exists, else app | The Next.js app directory. It must exist and resolve inside --root. With no --app and neither folder there, the command stops. |
--dry-run | off | Prints the absolute path of every file it would write, with (exists) after the ones already there, and writes nothing. |
--force | off | Writes even when the app directory has icon or link-preview files in the way (see Moving an app over), or a symlink leads outside --root. |
preview takes only --out. list and help take no options. Any other option is an error.
The CLI also follows symlinks before it writes: if public, public/brand, the app directory or a file in them is a symlink that leads outside --root (or a dangling one, which leads nowhere it can check), it lists the paths and stops, even on --dry-run. Point --root at the real folder, or pass --force. A symlink that stays inside --root is fine.
--help (or -h) and --version work with any command, or none. They're checked as soon as the options parse, so list --help prints the usage and nope --version prints the version, both exiting 0, even though the command alone would fail. With both, --help wins. An option that doesn't parse (unknown, missing its value, or a flag given one) still fails first: pools --bogus --help exits 1.
README banner
#Next.js serves public/ from the site root, so once an app has deployed its brand files, its banners have stable URLs: haruhime.moe's are https://www.haruhime.moe/brand/haruhime-banner.svg and https://www.haruhime.moe/brand/haruhime-banner-on-light.svg. Use the address the site answers on directly, with no redirect (for haruhime.moe that's www.haruhime.moe): GitHub loads README images through a proxy that may not follow one. To show the banner that matches the reader's GitHub theme, linked to the site, put this at the top of a README (a repo's, or an organization's profile/README.md in its .github repo):
<a href="https://www.haruhime.moe"> <picture> <source media="(prefers-color-scheme: light)" srcset="https://www.haruhime.moe/brand/haruhime-banner-on-light.svg"> <img alt="haruhime.moe: osu! tools for players, mappers and hosts" src="https://www.haruhime.moe/brand/haruhime-banner.svg" width="100%"> </picture></a>Without the link, clicking the banner opens the image instead of the site. Swap haruhime for the product and its site. The banner scales to the README's width, usually 640 to 830 pixels on GitHub, and the wordmark stays readable there.
Library repos like this one aren't products, so they have no brand files of their own. Their banners live on haruhime.moe under /brand/repos/ (this README's is https://www.haruhime.moe/brand/repos/brand-banner.svg). The haruhime.moe repo's bun run repo-banners draws them with bannerSvg, from a stand-in product: the repo's name in the parent brand's hue over the repo's own tagline. To add a banner for a new package, add the repo there, not here.
Moving an app over
#If the app directory already makes an icon or link preview another way (apple-icon.tsx, opengraph-image.tsx, icon.png, twitter-image.jpg, the favicon.ico create-next-app ships, …), Next.js would serve both, so the CLI stops and lists them. Delete them, since the generated files replace them, then run it again. --force writes anyway.
The first time a product is written into an app, a hand-made icon.svg, apple-icon.png, opengraph-image.png or its .alt.txt already sitting in the app directory is also treated as a conflict (it hasn't been through this CLI before, so it isn't safe to overwrite silently) and needs --force too. Once the product's public/brand/<name>-palette.json exists, later reruns replace those same files without --force, as before.
The check runs before --dry-run prints anything, so a dry run stops on the same conflicts.
API
#import { writeFileSync } from "node:fs";import { bannerSvg, iconSvg, ogSvg, palette, PRODUCTS, svgToPng, wordmarkSvg,} from "@haruhimemoe/brand";const pools = PRODUCTS.pools;console.log(palette(pools.hue).h1); // "#66ccff"writeFileSync("pools-wordmark-on-light.svg", wordmarkSvg(pools, { background: "light" }));writeFileSync("pools-apple-icon.png", svgToPng(iconSvg(pools, { shape: "square" }), 180));writeFileSync("pools-og.png", svgToPng(ogSvg(pools), 1200));writeFileSync("haruhime-banner.svg", bannerSvg(PRODUCTS.haruhime)); // the dark README bannerThe drawing functions take any Product, not only the ones in PRODUCTS:
import { type Product, wordmarkSvg } from "@haruhimemoe/brand";const demo: Product = { name: "demo", mark: "dm", hue: 30, tagline: "a demo tool", url: "https://example.com",};const svg = wordmarkSvg(demo);What the CLI does, from code:
import { brandFiles, metadataConflicts, PRODUCTS, writeBrandFiles } from "@haruhimemoe/brand";const root = process.cwd();const files = brandFiles(PRODUCTS.sheets, { appDir: "app" });const conflicts = metadataConflicts(root, "app", files);if (conflicts.length > 0) throw new Error(`Delete these first: ${conflicts.join(", ")}`);for (const written of writeBrandFiles(files, root)) console.log(written);Products
#| Export | Signature | What it is |
|---|---|---|
PRODUCTS | { haruhime, packs, pools, bb, sheets }, each a Product | The table above, keyed by name. |
isProductKey | (value: string) => value is ProductKey | True when value names a product in PRODUCTS. |
A Product is { name, mark, hue, tagline, url, writing, dos, donts }, plus an optional suffix:
name: lowercase, matching/^[a-z][a-z0-9-]*$/. It's drawn as the wordmark and used in file names.mark: the one or two lowercase letters on the icon.hue: an integer 0 to 359, the palette's hue.tagline: the line under the wordmark in the link preview and banner.url: the product's site.suffix: drawn half size on a second line under the name (haruhime's.moe), its first character in the highlight color. Without one (an empty string counts as none), the wordmark is the name and a round dot. Labels and alt text use the name plus the suffix (haruhime.moe).writing: how to write the name in running text, shown on the brand page (e.g. "pools, all lowercase. pools.haruhime.moe when you mean the site.").dos: at least two things the brand page tells people to do with the files.donts: at least two things the brand page tells people not to do with the logo.
Palette
#| Export | Signature | What it is |
|---|---|---|
palette | (hue: number) => Palette | Every token as "#rrggbb": b1 to b6 backgrounds (light to dark), c1 to c4 text colors, h1 and h2 highlights. |
TOKENS | { readonly b1: readonly [10, 40]; readonly b2: readonly [10, 30]; … } (a const object, a literal tuple per token) | The recipe: each token's saturation and lightness in percent. b1 to b6 are [10, 40], [10, 30], [10, 25], [10, 20], [10, 15] and [10, 10]; c1 to c4 are [40, 100] (white), [40, 90], [40, 80] and [40, 70]; h1 is [100, 70] and h2 is [50, 45]. |
hslToHex | (h: number, s: number, l: number) => string | "#rrggbb" for a hue in degrees and a saturation and lightness 0 to 100, the way browsers resolve hsl(): any hue angle works (-75 is 285), saturation and lightness outside 0 to 100 are clamped, and each channel is rounded. |
The palette is also its own entry, @haruhimemoe/brand/palette, with palette, TOKENS, hslToHex and the Palette and Token types. It imports nothing (no fonts, no file system, no PNG renderer), so it's safe in a browser bundle or an edge runtime, like a brand page that shows swatches:
import { hslToHex, palette } from "@haruhimemoe/brand/palette";palette(200).h1; // "#66ccff"hslToHex(200, 100, 70); // "#66ccff"Brand page data
#Since 0.7.0. Everything a /brand page needs for one product, built from PRODUCTS and palette: its name, writing guidance, dos and don'ts, palette, the seven files under public/brand/ (in the order a page shows them) and a link back to the family page.
| Export | Signature | What it is |
|---|---|---|
brandPageData | (key: ProductKey) => BrandPageData | The product's name (suffix included, fullName), mark, tagline, url, writing, dos, donts, palette(product.hue), its seven public/brand/ files as assets ({ label, href, dark }[]), the shared contact and a familyHref ("https://haruhime.moe/brand" for every tool, null on haruhime's own page). |
BRAND_CONTACT | "haruhime@haruhime.moe" | The address every brand page shows for licensing or usage questions. |
This is also its own entry, @haruhimemoe/brand/products: src/page.ts imports only ./products.js and ./palette.js (no fonts, no file system, no PNG renderer), so it's safe in a browser bundle or an edge runtime, and re-exports PRODUCTS, fullName, isProductKey and their types alongside brandPageData and BRAND_CONTACT:
import { brandPageData } from "@haruhimemoe/brand/products";const data = brandPageData("pools");data.name; // "pools"data.familyHref; // "https://haruhime.moe/brand"data.assets[0]; // { label: "Icon", href: "/brand/pools-icon.svg", dark: true }Drawings
#Each drawing is a complete SVG document as a string, with role="img" and an aria-label: the full name for the wordmark and icon (pools, haruhime.moe), and the full name and tagline for the link preview and banner.
| Export | Signature | What it is |
|---|---|---|
wordmarkSvg | (product: Product, options?: WordmarkOptions) => string | The wordmark (both lines, with a suffix), cropped to its ink with a 40-unit margin at a 1000-unit font size. It has a viewBox and no width or height, so it scales to its box. background: "dark" (default) draws white (c1) text and an h1 dot; "light" draws b6 text and an h2 dot. |
iconSvg | (product: Product, options?: IconOptions) => string | The mark and dot in c1 and h1 on a b6 square, in a 64×64 viewBox (no width or height). shape: "rounded" (default) rounds the corners; "square" is for platforms that round it themselves, like the Apple icon. |
ogSvg | (product: Product) => string | The 1200×630 link preview on b6: the wordmark over the tagline (in c3), left-aligned and centered vertically. |
bannerSvg | (product: Product, options?: BannerOptions) => string | The 1280×320 README banner with rounded corners: the wordmark over the tagline, centered. background: "dark" (default) is b6 with a c3 tagline; "light" is white with a b2 tagline. |
svgToPng | (svg: string, width: number) => Uint8Array | PNG bytes, width pixels wide, the height following the SVG's aspect ratio. It loads no system fonts, and loads @resvg/resvg-js on its first call. |
escapeXml | (value: string) => string | Escapes &, <, >, " and ' as numeric character references, for SVG or HTML. |
Page cards
#Since 0.6.0. A link preview per page (a pack, a pool, a template, a guide) in the ogSvg look: 1200×630 on b6, the product's wordmark at the top left and, anchored to the bottom padding (80px), an optional eyebrow in h1, the title in c1 and an optional subtitle in c3. The title takes 88px on up to 2 lines, else 76px or 64px on up to 3; past that the last line ends in "...". The subtitle takes up to 2 lines at 40px. Every line stays inside the padding.
| Export | Signature | What it is |
|---|---|---|
ogCardSvg | (product: Product, options: OgCardOptions) => string | The card as SVG. OgCardOptions is { title, subtitle?, eyebrow? }. The aria-label keeps the original text. |
ogCard | (product: Product, options: OgCardOptions) => Uint8Array | The same card as PNG bytes, through svgToPng. |
OG_CARD | { width: 1200, height: 630, padding: 80 } | Its size, for size exports and og:image:width. |
asciiText | (text: string) => string | What the card draws of user text: accents dropped (é to e), curly quotes, dashes, …, ·, × and ★ swapped for ASCII, anything else outside printable ASCII (CJK, emoji) left out, whitespace collapsed. A title with nothing left falls back to the product's tagline. |
fitLines | (text: string, options: FitOptions) => { lines: string[]; clamped: boolean } | Wraps ASCII text at spaces into at most maxLines lines of maxWidth (by advance) at a weight and size, breaking a word wider than a line, and ends the last line with "..." when it doesn't fit. |
Serving one from a Next.js route handler at request time (Node.js runtime, not edge):
// src/app/p/[slug]/og.png/route.tsimport { ogCard, PRODUCTS } from "@haruhimemoe/brand";export const runtime = "nodejs";export async function GET(_request: Request, { params }: RouteContext<"/p/[slug]/og.png">) { const pack = await findPublicPack((await params).slug); if (!pack) return new Response(null, { status: 404 }); const png = ogCard(PRODUCTS.packs, { eyebrow: "Mappool pack", title: pack.name, subtitle: "13 maps" }); return new Response(png, { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600, s-maxage=86400" }, });}Three things make that work on Vercel (and any serverless Node host). Install the package as a dependency, not a dev dependency. Keep it and resvg out of the bundle so the native binary and the fonts load from node_modules: serverExternalPackages: ["@haruhimemoe/brand", "@resvg/resvg-js"]. And trace the fonts, which are read by a computed path: outputFileTracingIncludes: { "/p/*/og.png": ["./node_modules/@haruhimemoe/brand/fonts/*.ttf"] } (the key is a picomatch glob over the route, so write a dynamic segment as *: [slug] would be a character class). A card takes about 60 to 80 ms to render; cache the response.
App files
#| Export | Signature | What it is |
|---|---|---|
brandFiles | (product: Product, options?: BrandFileOptions) => BrandFile[] | The 11 files the CLI writes, in the order of Files it writes, with paths relative to the app's root. options is { publicDir?, appDir? }, default "public" and "src/app" (it doesn't look for app/; the CLI does). A BrandFile is { path: string; contents: string | Uint8Array }: a string for SVG, JSON and text, bytes for PNG. |
metadataConflicts | (root: string, appDir: string, files: readonly BrandFile[]) => string[] | The files in appDir that writing files shouldn't silently touch, as appDir/<file>, sorted. That's every Next.js metadata file there (icon, apple-icon, opengraph-image or twitter-image, optionally numbered, as .ico, .png, .jpg, .jpeg, .gif, .svg, .js, .jsx, .ts or .tsx, and favicon.ico) that files doesn't replace by name, plus, while the product's <name>-palette.json doesn't exist yet, the ones it would overwrite. Empty when appDir doesn't exist. |
writeBrandFiles | (files: readonly BrandFile[], root: string) => string[] | Writes each file under root, creating folders and overwriting what's there, and returns the absolute paths. It doesn't check for conflicts. |
previewHtml | (products: readonly Product[]) => string | The preview page as one HTML document, with its images inlined. |
brandFiles and previewHtml render PNGs, so they load @resvg/resvg-js like svgToPng.
Text
#| Export | Signature | What it is |
|---|---|---|
layoutText | (text: string, options: TextOptions) => TextRun | Nunito text as SVG path data with the font's kerning, plus its exact ink box. |
TextOptions is { weight, size, x?, baseline?, tracking? }: weight is 400 or 800 (the two bundled weights), size the font size in SVG units, x the left edge of the first glyph's advance box (default 0), baseline the baseline's y, pointing down (default 0), and tracking extra space between letters in em (default 0).
A TextRun is { d, end, ink, line }: d is the path data for every glyph, end the pen's x after the last one, ink the drawn glyphs' exact Box ({ x1, y1, x2, y2 }), and line the font's line box at this size ({ top, bottom }).
layoutText draws with the bundled fonts, which are subset to printable ASCII. Any other character, including tabs and non-breaking spaces, throws rather than drawing a blank box.
Types
#Product, ProductKey, BrandPageData, BrandAsset, Palette, Token, OgCardOptions, FitOptions, WordmarkOptions, IconOptions, BannerOptions, BrandFile, BrandFileOptions, TextOptions, TextRun, Box and Weight are exported as types.
Errors
#hslToHex(h, s, l)throwsRangeErrorif any argument isn't a finite number.palette(hue)throwsRangeErrorifhueisn't an integer 0 to 359. Every drawing function,brandFilesandpreviewHtmlcall it, so a badproduct.huethrows there too.brandFiles(product, ...)throwsRangeErrorifproduct.namedoesn't match/^[a-z][a-z0-9-]*$/.layoutText(and sowordmarkSvg,iconSvg,ogSvg,bannerSvg, but notogCardSvg, which folds its text withasciiTextfirst) throwsErrorif the text has a character outside printable ASCII, or any whitespace besides a plain space (the bundled fonts don't have glyphs for them). That covers a product'sname,mark,suffixandtagline.svgToPng(and sobrandFiles,previewHtml, and the CLI's product andpreviewcommands) fails if@resvg/resvg-jshas no native binary for the platform. See Compatibility.- The CLI exits 0 when it's done, and for
--helpor--versionwith any command (see Options). It exits 1, with a message on stderr, for:- no command, or more than one (it prints the usage);
- an option it doesn't know,
--root,--public,--appor--outwithout a value, or--dry-run,--force,--helpor--versiongiven one (--force=1); - an option the command doesn't take, like
preview --forceorpools --out x; - an unknown product;
--appor--publicresolving outside--root, or, without--force, a symlink that would take a write outside it;- no app directory: neither
src/appnorappunder--root, or an--appthat doesn't exist; - icon or link-preview files in the way, without
--force(see Moving an app over).
- Anything else that goes wrong on disk, like a folder the CLI can't write to, or an
--app,--publicor--outpath that is a file, throws. The CLI exits 1 and Node prints the error (EACCES,ENOTDIR,EEXIST) with a stack trace, not a one-line message. Files written before a failed write stay.
Compatibility
#Node 22.12 or later. The package is ES modules with TypeScript types.
svgToPng (and so the CLI's PNG output) needs @resvg/resvg-js's native binary; 2.6.2 ships prebuilt binaries for macOS (x64, arm64), Windows (x64, ia32, arm64), Linux glibc and musl (x64, arm64), Linux armv7 (gnueabihf) and Android (arm64, arm-eabi). It's loaded lazily, so importing the package for palette, PRODUCTS or layoutText alone never touches it, even on a platform or an --omit=optional install without it.
The CLI writes Next.js App Router metadata files (icon.svg, apple-icon.png, opengraph-image.png), so it's for Next.js apps. The API works in any Node program.
The CLI and the brand files are build-time: run them in Node, at build or from a script. Page cards (ogCard) may also run per request in a Node.js server route (see Page cards for the Next.js config). Never import @haruhimemoe/brand into a browser bundle or an edge runtime. The exceptions are @haruhimemoe/brand/palette and @haruhimemoe/brand/products (Brand page data), which import nothing build-time and run anywhere.
License
#MIT. Nunito (in fonts/) is under the SIL Open Font License 1.1; see LICENSE and fonts/OFL.txt.
See CHANGELOG.md for release history and CONTRIBUTING.md to contribute. Report security issues as SECURITY.md describes. Bring questions and feedback to the haruhime.moe Discord server.