Skip to content
Contents

@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

bun add @haruhimemoe/brand

The brand kit for haruhime.moe and its osu! tools (packs, pools, bb, sheets), generated from one table:

ProductMarkHue
haruhime (the parent site, haruhime.moe)h.333 (pink)
packspk.30 (orange)
poolspl.200 (blue)
bbbb.265 (violet)
sheetssh.150 (green)
  • Palette from one hue: six backgrounds, four text colors and two highlights, as hex values (palette, TOKENS and 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's theme.css, which haruhime.moe, packs.haruhime.moe and pools.haruhime.moe import: there, an app sets --hue and the CSS does the rest.
  • Wordmark: the name in Nunito ExtraBold plus a dot in the highlight color (the deeper h2 on light backgrounds, where h1 is 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

#
sh
bun add -d @haruhimemoe/brand

With 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

#
sh
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 product

With npm, run it as npx haruhime-brand.

CommandWhat 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 listPrints 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 --versionPrints haruhime-brand <version>.

list prints:

text
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.moe

preview 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):

FileFor
public/brand/<name>-wordmark.svgdark backgrounds (white text)
public/brand/<name>-wordmark-on-light.svglight backgrounds (dark text)
public/brand/<name>-icon.svgbrand page, schema.org logo
public/brand/<name>-banner.svgREADME banner, dark background (1280×320)
public/brand/<name>-banner-on-light.svgREADME banner, white background
public/brand/<name>-banner.pngthe 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.svgfavicon (Next.js serves it by name)
src/app/apple-icon.png180×180, square corners (iOS rounds them)
src/app/opengraph-image.png + .alt.txtthe 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:

OptionDefaultMeaning
--root <dir>.The app's root folder, resolved from where you run the command. The other paths resolve against it.
--public <dir>publicThe app's static folder. It must resolve inside --root, symlinks followed (see below).
--app <dir>src/app if it exists, else appThe Next.js app directory. It must exist and resolve inside --root. With no --app and neither folder there, the command stops.
--dry-runoffPrints the absolute path of every file it would write, with (exists) after the ones already there, and writes nothing.
--forceoffWrites 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):

html
<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

#
ts
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 banner

The drawing functions take any Product, not only the ones in PRODUCTS:

ts
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:

ts
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

#
ExportSignatureWhat it is
PRODUCTS{ haruhime, packs, pools, bb, sheets }, each a ProductThe table above, keyed by name.
isProductKey(value: string) => value is ProductKeyTrue 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

#
ExportSignatureWhat it is
palette(hue: number) => PaletteEvery 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:

ts
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.

ExportSignatureWhat it is
brandPageData(key: ProductKey) => BrandPageDataThe 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:

ts
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.

ExportSignatureWhat it is
wordmarkSvg(product: Product, options?: WordmarkOptions) => stringThe 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) => stringThe 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) => stringThe 1200×630 link preview on b6: the wordmark over the tagline (in c3), left-aligned and centered vertically.
bannerSvg(product: Product, options?: BannerOptions) => stringThe 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) => Uint8ArrayPNG 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) => stringEscapes &, <, >, " 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.

ExportSignatureWhat it is
ogCardSvg(product: Product, options: OgCardOptions) => stringThe card as SVG. OgCardOptions is { title, subtitle?, eyebrow? }. The aria-label keeps the original text.
ogCard(product: Product, options: OgCardOptions) => Uint8ArrayThe 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) => stringWhat 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):

ts
// 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

#
ExportSignatureWhat 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[]) => stringThe preview page as one HTML document, with its images inlined.

brandFiles and previewHtml render PNGs, so they load @resvg/resvg-js like svgToPng.

Text

#
ExportSignatureWhat it is
layoutText(text: string, options: TextOptions) => TextRunNunito 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) throws RangeError if any argument isn't a finite number.
  • palette(hue) throws RangeError if hue isn't an integer 0 to 359. Every drawing function, brandFiles and previewHtml call it, so a bad product.hue throws there too.
  • brandFiles(product, ...) throws RangeError if product.name doesn't match /^[a-z][a-z0-9-]*$/.
  • layoutText (and so wordmarkSvg, iconSvg, ogSvg, bannerSvg, but not ogCardSvg, which folds its text with asciiText first) throws Error if 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's name, mark, suffix and tagline.
  • svgToPng (and so brandFiles, previewHtml, and the CLI's product and preview commands) fails if @resvg/resvg-js has no native binary for the platform. See Compatibility.
  • The CLI exits 0 when it's done, and for --help or --version with 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, --app or --out without a value, or --dry-run, --force, --help or --version given one (--force=1);
    • an option the command doesn't take, like preview --force or pools --out x;
    • an unknown product;
    • --app or --public resolving outside --root, or, without --force, a symlink that would take a write outside it;
    • no app directory: neither src/app nor app under --root, or an --app that 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, --public or --out path 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.