branding-engine 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +433 -73
  2. package/bin/cli.mjs +8 -5
  3. package/examples/severino-labs/README.md +95 -0
  4. package/examples/severino-labs/brand.json +30 -0
  5. package/examples/severino-labs/generated/cards/social-card.png +0 -0
  6. package/examples/severino-labs/generated/severino-labs/README.md +49 -0
  7. package/examples/severino-labs/generated/severino-labs/icons/apple-touch-icon.png +0 -0
  8. package/examples/severino-labs/generated/severino-labs/icons/favicon-192.png +0 -0
  9. package/examples/severino-labs/generated/severino-labs/icons/favicon-32.png +0 -0
  10. package/examples/severino-labs/generated/severino-labs/icons/favicon.ico +0 -0
  11. package/examples/severino-labs/generated/severino-labs/icons/favicon.svg +1 -0
  12. package/examples/severino-labs/generated/severino-labs/mark/mark-1024.png +0 -0
  13. package/examples/severino-labs/generated/severino-labs/mark/mark-512.png +0 -0
  14. package/examples/severino-labs/generated/severino-labs/mark/mark-transparent-dark.png +0 -0
  15. package/examples/severino-labs/generated/severino-labs/mark/mark-transparent-light.png +0 -0
  16. package/examples/severino-labs/generated/severino-labs/mark/mark.svg +1 -0
  17. package/examples/severino-labs/generated/severino-labs/sheet/overview.png +0 -0
  18. package/examples/severino-labs/generated/severino-labs/sheet/palette.png +0 -0
  19. package/examples/severino-labs/generated/severino-labs/sheet/sheet-mark.png +0 -0
  20. package/examples/severino-labs/generated/severino-labs/sheet/type-specimen.png +0 -0
  21. package/examples/severino-labs/generated/severino-labs/web/head.html +6 -0
  22. package/examples/severino-labs/generated/severino-labs/web/site.webmanifest +19 -0
  23. package/examples/severino-labs/generated/severino-labs/web/tokens.css +7 -0
  24. package/examples/severino-labs/generated/severino-labs/wordmark/wordmark-caps-dark.png +0 -0
  25. package/examples/severino-labs/generated/severino-labs/wordmark/wordmark-caps-light.png +0 -0
  26. package/examples/severino-labs/generated/severino-labs/wordmark/wordmark-caps.svg +1 -0
  27. package/examples/severino-labs/generated/severino-labs/wordmark/wordmark-dark.png +0 -0
  28. package/examples/severino-labs/generated/severino-labs/wordmark/wordmark-light.png +0 -0
  29. package/examples/severino-labs/generated/severino-labs/wordmark/wordmark.svg +1 -0
  30. package/examples/severino-labs/studio.jpg +0 -0
  31. package/index.mjs +1 -0
  32. package/package.json +13 -6
  33. package/src/build.mjs +8 -6
  34. package/src/lib/extract-glyphs.mjs +65 -0
  35. package/src/lib/glyphs.mjs +6 -19
  36. package/src/lib/identity.mjs +11 -0
  37. package/src/lib/mark.mjs +28 -8
  38. package/src/lib/render.mjs +11 -2
  39. package/src/lib/wordmark.mjs +6 -1
  40. package/src/make-mark.mjs +2 -0
  41. package/src/make-sheet.mjs +2 -0
  42. package/src/make-web.mjs +2 -0
  43. package/src/make-wordmark.mjs +3 -1
  44. package/src/site.mjs +3 -1
  45. package/requirements.txt +0 -3
  46. package/src/lib/extract-glyphs.py +0 -75
package/src/lib/mark.mjs CHANGED
@@ -4,18 +4,29 @@
4
4
  //
5
5
  // Outlines come from brand-glyphs.json (A-Z and 0-9 are bundled by default).
6
6
  import { loadGlyphs } from './glyphs.mjs';
7
+ import { normalizeGlyph } from './identity.mjs';
7
8
 
8
9
  // Read glyphs lazily so a BRAND_GLYPHS set in-process (by the build) is honored.
9
10
  // Defaults to brand-glyphs.json; point at another extracted set with BRAND_GLYPHS.
10
11
  let _glyphData;
12
+ let _glyphFile;
11
13
  function glyphData() {
12
- if (!_glyphData) _glyphData = loadGlyphs(process.env.BRAND_GLYPHS || 'brand-glyphs.json');
14
+ const file = process.env.BRAND_GLYPHS || 'brand-glyphs.json';
15
+ if (!_glyphData || _glyphFile !== file) {
16
+ _glyphData = loadGlyphs(file);
17
+ _glyphFile = file;
18
+ }
13
19
  return _glyphData;
14
20
  }
15
21
 
16
22
  const RENDER = {
17
23
  letterSpacing: -0.045, // em
18
- widthRatio: 0.63, // glyph ink width relative to the box
24
+ widthRatio: {
25
+ 1: 0.46,
26
+ 2: 0.63,
27
+ 3: 0.76,
28
+ },
29
+ heightRatio: 0.56, // cap narrow glyphs such as "I" by height
19
30
  radiusRatio: 0.22, // rounded-square corner radius (0 = square)
20
31
  };
21
32
 
@@ -31,8 +42,7 @@ function layout(glyph) {
31
42
  if (!g) {
32
43
  throw new Error(
33
44
  `No outline for glyph "${ch}". Available: ${Object.keys(glyphs).join(', ')}. ` +
34
- `It will be extracted automatically for the bundled font; for a custom ` +
35
- `font, ensure python3 + fonttools are installed.`,
45
+ `Re-run through buildKit or buildBrand so the Node extractor can cache it.`,
36
46
  );
37
47
  }
38
48
  placed.push({ x: penX, g });
@@ -45,7 +55,14 @@ function layout(glyph) {
45
55
  yMin = Math.min(yMin, g.bounds.yMin);
46
56
  yMax = Math.max(yMax, g.bounds.yMax);
47
57
  }
48
- return { placed, cx: (xMin + xMax) / 2, cy: (yMin + yMax) / 2, gw: xMax - xMin };
58
+ return {
59
+ placed,
60
+ count: chars.length,
61
+ cx: (xMin + xMax) / 2,
62
+ cy: (yMin + yMax) / 2,
63
+ gw: xMax - xMin,
64
+ gh: yMax - yMin,
65
+ };
49
66
  }
50
67
 
51
68
  /**
@@ -55,11 +72,14 @@ function layout(glyph) {
55
72
  * @param {boolean} [opts.rounded=true] rounded-square (false = full square)
56
73
  * @param {string|null} [opts.bg] tile fill, or null for transparent
57
74
  * @param {string} [opts.fg='#ffffff'] glyph fill
58
- * @param {string} [opts.glyph='JS'] monogram (J and S are the extracted outlines)
75
+ * @param {string} [opts.glyph='JS'] 1-3 alphanumeric mark characters
59
76
  */
60
77
  export function markSvg({ size = 512, rounded = true, bg, fg = '#ffffff', glyph = 'JS' } = {}) {
61
- const { placed, cx, cy, gw } = layout(glyph);
62
- const s = (RENDER.widthRatio * size) / gw;
78
+ const normalizedGlyph = normalizeGlyph(glyph);
79
+ const { placed, count, cx, cy, gw, gh } = layout(normalizedGlyph);
80
+ const widthScale = (RENDER.widthRatio[count] * size) / gw;
81
+ const heightScale = (RENDER.heightRatio * size) / gh;
82
+ const s = Math.min(widthScale, heightScale);
63
83
  const rx = rounded ? +(size * RENDER.radiusRatio).toFixed(2) : 0;
64
84
  const bgRect = bg ? `<rect width="${size}" height="${size}" rx="${rx}" fill="${bg}"/>` : '';
65
85
  const paths = placed
@@ -8,9 +8,18 @@ import { fontPath } from './font.mjs';
8
8
 
9
9
  // The bundled font embedded as a data URI, as an @font-face for `family`.
10
10
  export function fontFaceCss(family) {
11
- const b64 = readFileSync(fontPath()).toString('base64');
11
+ const font = fontPath();
12
+ const ext = font.split('.').pop().toLowerCase();
13
+ const formats = {
14
+ otf: ['font/otf', 'opentype'],
15
+ ttf: ['font/ttf', 'truetype'],
16
+ woff: ['font/woff', 'woff'],
17
+ woff2: ['font/woff2', 'woff2'],
18
+ };
19
+ const [mime, format] = formats[ext] || ['application/octet-stream', ext];
20
+ const b64 = readFileSync(font).toString('base64');
12
21
  return `@font-face{font-family:${family};font-weight:200 900;font-display:block;` +
13
- `src:url(data:font/woff2;base64,${b64}) format('woff2')}`;
22
+ `src:url(data:${mime};base64,${b64}) format('${format}')}`;
14
23
  }
15
24
 
16
25
  // Playwright is an optional dependency: only the sheet and social-card renderers
@@ -21,8 +21,13 @@ const LETTER_SPACING = { title: -0.03125, caps: -0.02 }; // em
21
21
  // Read glyphs lazily so a cache (re)written earlier in the same process, or a
22
22
  // BRAND_WORDMARK_GLYPHS set by build.mjs, is honored on first render.
23
23
  let _glyphData;
24
+ let _glyphFile;
24
25
  function glyphData() {
25
- if (!_glyphData) _glyphData = loadGlyphs(wordmarkGlyphFile());
26
+ const file = wordmarkGlyphFile();
27
+ if (!_glyphData || _glyphFile !== file) {
28
+ _glyphData = loadGlyphs(file);
29
+ _glyphFile = file;
30
+ }
26
31
  return _glyphData;
27
32
  }
28
33
 
package/src/make-mark.mjs CHANGED
@@ -8,10 +8,12 @@ import path from 'node:path';
8
8
  import sharp from 'sharp';
9
9
  import { markSvg } from './lib/mark.mjs';
10
10
  import { normalizeHex } from './lib/color.mjs';
11
+ import { normalizeGlyph } from './lib/identity.mjs';
11
12
  import { pngsToIco } from './lib/ico.mjs';
12
13
 
13
14
  export async function makeMark({ slug, hex, glyph = 'JS', outDir }) {
14
15
  const fill = normalizeHex(hex);
16
+ glyph = normalizeGlyph(glyph);
15
17
  const iconsDir = path.join(outDir, slug, 'icons');
16
18
  const markDir = path.join(outDir, slug, 'mark');
17
19
  fs.mkdirSync(iconsDir, { recursive: true });
@@ -7,6 +7,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import sharp from 'sharp';
9
9
  import { markSvg } from './lib/mark.mjs';
10
+ import { normalizeGlyph } from './lib/identity.mjs';
10
11
  import { fontPath } from './lib/font.mjs';
11
12
  import { darken, normalizeHex } from './lib/color.mjs';
12
13
  import { esc } from './lib/html.mjs';
@@ -21,6 +22,7 @@ function prettyFont(file) {
21
22
  }
22
23
 
23
24
  export async function makeSheet({ slug, hex, glyph = 'JS', wordmark, deep, browser, outDir }) {
25
+ glyph = normalizeGlyph(glyph);
24
26
  const fill = normalizeHex(hex);
25
27
  const deepShade = deep ? normalizeHex(deep) : darken(fill);
26
28
  const title = wordmark || glyph;
package/src/make-web.mjs CHANGED
@@ -5,9 +5,11 @@
5
5
  import { mkdirSync, writeFileSync } from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { darken, normalizeHex } from './lib/color.mjs';
8
+ import { normalizeGlyph } from './lib/identity.mjs';
8
9
 
9
10
  export function makeWeb({ slug, hex, glyph = 'JS', name, deep, onColor, outDir }) {
10
11
  const accent = normalizeHex(hex);
12
+ glyph = normalizeGlyph(glyph);
11
13
  const deepShade = deep ? normalizeHex(deep) : darken(accent);
12
14
  const onAccent = onColor ? normalizeHex(onColor) : '#ffffff';
13
15
  const label = name || slug;
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import sharp from 'sharp';
9
9
  import { wordmarkSvg } from './lib/wordmark.mjs';
10
10
  import { WORDMARK_CHARS, ensureGlyphs, wordmarkGlyphFile } from './lib/glyphs.mjs';
11
+ import { normalizeGlyph } from './lib/identity.mjs';
11
12
 
12
13
  const INK = { light: '#0b0620', dark: '#ffffff' }; // ink for light / dark backgrounds
13
14
  const PNG_HEIGHT = 512; // raster export height; SVG stays the source of truth
@@ -17,13 +18,14 @@ const VARIANTS = [
17
18
  ];
18
19
 
19
20
  export async function makeWordmark({ slug, hex, text, glyph = 'JS', weight = 700, outDir }) {
21
+ glyph = normalizeGlyph(glyph);
20
22
  const dir = path.join(outDir, slug, 'wordmark');
21
23
  mkdirSync(dir, { recursive: true });
22
24
 
23
25
  // The build pre-extracts, but a one-off kit lands straight here, so make sure
24
26
  // the cache exists. It bundles the whole alphabet (WORDMARK_CHARS) so a one-off
25
27
  // never overwrites the shared set with just its own name's letters.
26
- ensureGlyphs({ file: wordmarkGlyphFile(), weight, chars: WORDMARK_CHARS, label: 'wordmark glyphs' });
28
+ await ensureGlyphs({ file: wordmarkGlyphFile(), weight, chars: WORDMARK_CHARS, label: 'wordmark glyphs' });
27
29
 
28
30
  const toPng = (svg) => sharp(Buffer.from(svg)).resize({ height: PNG_HEIGHT }).png().toBuffer();
29
31
  for (const { caps, base } of VARIANTS) {
package/src/site.mjs CHANGED
@@ -11,6 +11,7 @@ import path from 'node:path';
11
11
  import sharp from 'sharp';
12
12
  import { markSvg } from './lib/mark.mjs';
13
13
  import { darken, normalizeHex } from './lib/color.mjs';
14
+ import { normalizeGlyph } from './lib/identity.mjs';
14
15
  import { pngsToIco } from './lib/ico.mjs';
15
16
 
16
17
  const DEFAULT_CONFIG = {
@@ -27,6 +28,7 @@ function headSnippet(accent) {
27
28
  '<link rel="icon" type="image/svg+xml" href="/favicon.svg" />',
28
29
  '<link rel="apple-touch-icon" href="/apple-touch-icon.png" />',
29
30
  '<link rel="manifest" href="/site.webmanifest" />',
31
+ '<link rel="stylesheet" href="/brand-tokens.css" />',
30
32
  `<meta name="theme-color" content="${accent}" />`,
31
33
  ].join('\n');
32
34
  }
@@ -69,7 +71,7 @@ export async function generateSite({ config, publicDir = 'public', cwd = process
69
71
  const accent = normalizeHex(cfg.accent);
70
72
  const onAccent = cfg.onColor ? normalizeHex(cfg.onColor) : '#ffffff';
71
73
  const deep = cfg.deep ? normalizeHex(cfg.deep) : darken(accent);
72
- const glyph = cfg.glyph || 'JS';
74
+ const glyph = normalizeGlyph(cfg.glyph || 'JS');
73
75
  const name = cfg.name || 'Site';
74
76
 
75
77
  const pub = path.resolve(cwd, publicDir);
package/requirements.txt DELETED
@@ -1,3 +0,0 @@
1
- # For ./glyphs.sh (glyph extraction). Install with: pip install -r requirements.txt
2
- fonttools>=4.60
3
- brotli>=1.1
@@ -1,75 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Extract glyph outlines as SVG path data for the brand mark.
3
-
4
- Defaults to the bundled Inter variable woff2, but any font works: pass a path to
5
- a .ttf/.otf/.woff2. Variable fonts are instantiated at the given weight; static
6
- fonts use their single master. Output is JSON the node generators consume, so
7
- they stay pure-node.
8
-
9
- Usage:
10
- python3 bin/lib/extract-glyphs.py "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 800
11
- python3 bin/lib/extract-glyphs.py "AB" 700 ~/fonts/SomeFont.ttf
12
- python3 bin/lib/extract-glyphs.py "AB" 700 ~/fonts/SomeFont.ttf myfont-glyphs.json
13
-
14
- The default run writes brand-glyphs.json (the kit's glyph cache). A custom font
15
- writes <font-stem>-glyphs.json unless you name the output, so the brand cache is
16
- never clobbered by accident. Point a generator at a non-default file with
17
- BRAND_GLYPHS=<file> (e.g. BRAND_GLYPHS=somefont-glyphs.json ./kit.sh ...).
18
- """
19
- import json
20
- import sys
21
- from pathlib import Path
22
-
23
- from fontTools.ttLib import TTFont
24
- from fontTools.varLib.instancer import instantiateVariableFont
25
- from fontTools.pens.svgPathPen import SVGPathPen
26
- from fontTools.pens.boundsPen import BoundsPen
27
-
28
- here = Path(__file__).resolve().parent
29
- root = here.parents[1]
30
- default_font = root / "assets/fonts/inter/inter-variable-latin.woff2"
31
-
32
- chars = sys.argv[1] if len(sys.argv) > 1 else "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
33
- weight = float(sys.argv[2]) if len(sys.argv) > 2 else 800.0
34
- font_path = Path(sys.argv[3]).expanduser() if len(sys.argv) > 3 else default_font
35
-
36
- if len(sys.argv) > 4:
37
- out_path = Path(sys.argv[4])
38
- if not out_path.is_absolute():
39
- out_path = here / out_path
40
- elif len(sys.argv) > 3:
41
- # Custom font, no explicit output: derive a name so Inter is never clobbered.
42
- out_path = here / f"{font_path.stem}-glyphs.json"
43
- else:
44
- out_path = here / "brand-glyphs.json"
45
-
46
- font = TTFont(str(font_path))
47
- if "fvar" in font:
48
- instantiateVariableFont(font, {"wght": weight}, inplace=True)
49
-
50
- glyph_set = font.getGlyphSet()
51
- cmap = font.getBestCmap()
52
- upem = font["head"].unitsPerEm
53
-
54
- glyphs = {}
55
- for ch in dict.fromkeys(chars):
56
- name = cmap[ord(ch)]
57
- g = glyph_set[name]
58
- pen = SVGPathPen(glyph_set)
59
- g.draw(pen)
60
- bp = BoundsPen(glyph_set)
61
- g.draw(bp)
62
- # Blank glyphs (e.g. the space) have no contours, so BoundsPen leaves
63
- # bounds None; they still carry an advance width the layout needs.
64
- xmin, ymin, xmax, ymax = bp.bounds or (0, 0, 0, 0)
65
- glyphs[ch] = {
66
- "path": pen.getCommands(),
67
- "advance": g.width,
68
- "bounds": {"xMin": xmin, "yMin": ymin, "xMax": xmax, "yMax": ymax},
69
- }
70
-
71
- out_path.write_text(json.dumps(
72
- {"font": font_path.name, "unitsPerEm": upem, "weight": weight, "glyphs": glyphs},
73
- indent=2,
74
- ))
75
- print(f"wrote {out_path.name} from {font_path.name} — upem={upem} weight={int(weight)} chars={''.join(glyphs)}")