@arraypress/covers 1.1.0 → 1.4.0

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 (259) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +18 -809
  3. package/package.json +3 -1
  4. package/recipes/flyers/afterglow-motion.json +540 -0
  5. package/recipes/flyers/afterglow.json +70 -0
  6. package/recipes/flyers/arcfield-mirror-motion.json +397 -0
  7. package/recipes/flyers/arcfield-mirror.json +364 -0
  8. package/recipes/flyers/bauhaus-oktav.json +319 -0
  9. package/recipes/flyers/bauhaus-ritmo.json +735 -0
  10. package/recipes/flyers/bauhaus-volta.json +394 -0
  11. package/recipes/flyers/bloomfall.json +99 -0
  12. package/recipes/flyers/drag-solenne-motion.json +444 -0
  13. package/recipes/flyers/drag-solenne.json +426 -0
  14. package/recipes/flyers/fennick-patches.json +874 -0
  15. package/recipes/flyers/ferro-pass.json +398 -0
  16. package/recipes/flyers/ferrova.json +812 -0
  17. package/recipes/flyers/grid-poster.json +91 -0
  18. package/recipes/flyers/grid-rest-rhythm.json +125 -0
  19. package/recipes/flyers/groovy-honeycomb.json +816 -0
  20. package/recipes/flyers/groovy-marigold.json +479 -0
  21. package/recipes/flyers/groovy-peachfuzz.json +974 -0
  22. package/recipes/flyers/halden-orbit-motion.json +381 -0
  23. package/recipes/flyers/halden-orbit.json +356 -0
  24. package/recipes/flyers/halvard.json +120 -0
  25. package/recipes/flyers/kairo-weekender.json +696 -0
  26. package/recipes/flyers/linger-motion.json +518 -0
  27. package/recipes/flyers/linger.json +67 -0
  28. package/recipes/flyers/little-orchard.json +105 -0
  29. package/recipes/flyers/lofi-hexmoth.json +912 -0
  30. package/recipes/flyers/lofi-lowbit-motion.json +888 -0
  31. package/recipes/flyers/lofi-lowbit.json +880 -0
  32. package/recipes/flyers/lofi-nullpoint.json +3013 -0
  33. package/recipes/flyers/logofolio.json +60 -0
  34. package/recipes/flyers/lumio-stack-motion.json +648 -0
  35. package/recipes/flyers/lumio-stack.json +438 -0
  36. package/recipes/flyers/magazine-cover.json +168 -0
  37. package/recipes/flyers/magazine-extended.json +269 -0
  38. package/recipes/flyers/magazine-knockout.json +40 -0
  39. package/recipes/flyers/northfold-varsity.json +618 -0
  40. package/recipes/flyers/open-studio.json +69 -0
  41. package/recipes/flyers/orrin-merch.json +715 -0
  42. package/recipes/flyers/oxyde.json +113 -0
  43. package/recipes/flyers/pixel-mosaic.json +58 -0
  44. package/recipes/flyers/quiet-fields.json +610 -0
  45. package/recipes/flyers/scrapbook-halcyon.json +948 -0
  46. package/recipes/flyers/solenne-ticket.json +730 -0
  47. package/recipes/flyers/solvane-motion.json +576 -0
  48. package/recipes/flyers/solvane.json +98 -0
  49. package/recipes/flyers/subduct.json +99 -0
  50. package/recipes/flyers/sunvale-lineup.json +584 -0
  51. package/recipes/flyers/tidewell.json +91 -0
  52. package/recipes/flyers/tour-kairo.json +535 -0
  53. package/recipes/flyers/velora-stickers.json +885 -0
  54. package/recipes/flyers/wheatpaste-black.json +313 -0
  55. package/recipes/flyers/wheatpaste-mark.json +198 -0
  56. package/recipes/flyers/wheatpaste-torn.json +114 -0
  57. package/recipes/flyers/wheatpaste-wall.json +984 -0
  58. package/recipes/flyers/wheatpaste-words.json +233 -0
  59. package/recipes/release/aura.json +137 -0
  60. package/recipes/release/chrome.json +433 -0
  61. package/recipes/release/collage.json +372 -0
  62. package/recipes/release/duotone.json +113 -0
  63. package/recipes/release/film-still.json +102 -0
  64. package/recipes/release/headline.json +70 -0
  65. package/recipes/release/instant.json +170 -0
  66. package/recipes/release/label.json +364 -0
  67. package/recipes/release/liquid-glass.json +301 -0
  68. package/recipes/release/odd-tile-dark.json +688 -0
  69. package/recipes/release/odd-tile-dense.json +1056 -0
  70. package/recipes/release/odd-tile-shift.json +541 -0
  71. package/recipes/release/odd-tile-split.json +571 -0
  72. package/recipes/release/odd-tile.json +633 -0
  73. package/recipes/release/off-edge.json +96 -0
  74. package/recipes/release/plinth.json +794 -0
  75. package/recipes/release/riso.json +108 -0
  76. package/recipes/release/series/fluid-label.json +561 -0
  77. package/recipes/release/series/fluid-pixel.json +334 -0
  78. package/recipes/release/series/hairline.json +305 -0
  79. package/recipes/release/series/halo.json +282 -0
  80. package/recipes/release/series/mark-crop.json +135 -0
  81. package/recipes/release/series/mark-window.json +150 -0
  82. package/recipes/release/series/photo-label.json +475 -0
  83. package/recipes/release/series/smear-field.json +345 -0
  84. package/recipes/release/series/smear.json +202 -0
  85. package/recipes/release/series/spin.json +172 -0
  86. package/recipes/release/series/split-ghost.json +230 -0
  87. package/recipes/release/series/zoom.json +168 -0
  88. package/recipes/release/small-print.json +114 -0
  89. package/recipes/release/stack.json +156 -0
  90. package/recipes/release/swiss.json +117 -0
  91. package/schema/recipe.schema.json +638 -69
  92. package/src/animate/index.d.ts +9 -19
  93. package/src/animate/index.js +6 -111
  94. package/src/animate/keyframes.d.ts +21 -0
  95. package/src/animate/keyframes.js +119 -0
  96. package/src/animate/marquee.d.ts +30 -0
  97. package/src/animate/marquee.js +93 -0
  98. package/src/animate/morph.d.ts +8 -0
  99. package/src/animate/morph.js +99 -0
  100. package/src/animate/motion.d.ts +59 -0
  101. package/src/animate/motion.js +63 -0
  102. package/src/animate/sheet.d.ts +19 -0
  103. package/src/animate/sheet.js +61 -0
  104. package/src/animate/timing.d.ts +28 -0
  105. package/src/animate/timing.js +146 -0
  106. package/src/animate/tracks.d.ts +18 -0
  107. package/src/animate/tracks.js +139 -0
  108. package/src/diagram.js +4 -3
  109. package/src/extract.js +2 -2
  110. package/src/fonts.js +1 -2
  111. package/src/geometry.js +4 -34
  112. package/src/grid.js +1 -1
  113. package/src/handmade.js +9 -7
  114. package/src/icons.js +1 -2
  115. package/src/index.d.ts +47 -54
  116. package/src/index.js +5 -5
  117. package/src/inter-data.js +1 -1
  118. package/src/json-recipe.js +28 -7
  119. package/src/layer-options.js +26 -4
  120. package/src/layers/barcode.js +3 -3
  121. package/src/layers/geometry.js +5 -5
  122. package/src/layers/index.d.ts +6 -0
  123. package/src/layers/index.js +3 -0
  124. package/src/layers/mark-geometry.d.ts +15 -0
  125. package/src/layers/mark-geometry.js +46 -0
  126. package/src/layers/marks.d.ts +25 -0
  127. package/src/layers/marks.js +102 -0
  128. package/src/layers/mosaic.d.ts +32 -0
  129. package/src/layers/mosaic.js +155 -0
  130. package/src/layers/perforation.js +3 -3
  131. package/src/layers/repeat.js +2 -2
  132. package/src/layers/shape.d.ts +2 -2
  133. package/src/layers/shape.js +5 -5
  134. package/src/layers/soft.js +2 -2
  135. package/src/layers/svg.js +2 -3
  136. package/src/layers/table.js +3 -3
  137. package/src/layers/tear.d.ts +1 -1
  138. package/src/layers/tear.js +4 -4
  139. package/src/layers/texture.js +3 -3
  140. package/src/layers/util.js +15 -2
  141. package/src/layers.js +17 -32
  142. package/src/{effects/paint.js → paint/index.js} +5 -5
  143. package/src/{effects/paint-presets.d.ts → paint/presets.d.ts} +1 -1
  144. package/src/{effects/paint-presets.js → paint/presets.js} +1 -1
  145. package/src/{effects → paint}/tile.d.ts +1 -1
  146. package/src/{effects → paint}/tile.js +4 -4
  147. package/src/palette.js +1 -1
  148. package/src/platform/files.d.ts +6 -0
  149. package/src/platform/files.js +41 -0
  150. package/src/platform/harfbuzz.js +1 -1
  151. package/src/platform/index.d.ts +3 -3
  152. package/src/platform/index.js +6 -4
  153. package/src/platform/load.js +1 -2
  154. package/src/print.js +9 -7
  155. package/src/recipes.js +1 -1
  156. package/src/render/backdrop.js +2 -2
  157. package/src/render/grain.js +1 -1
  158. package/src/render/group.js +3 -3
  159. package/src/render/key.js +1 -2
  160. package/src/{effects → render}/transform.d.ts +2 -1
  161. package/src/{effects → render}/transform.js +17 -5
  162. package/src/render/use.d.ts +1 -1
  163. package/src/render/use.js +2 -2
  164. package/src/render/wrap.js +5 -60
  165. package/src/render.js +55 -18
  166. package/src/text.js +1 -1
  167. package/src/type/auto-color.js +1 -1
  168. package/src/type/fallback.js +1 -1
  169. package/src/type/glyphs.js +2 -2
  170. package/src/type/line.js +1 -1
  171. package/src/type/live.js +1 -1
  172. package/src/type/path.js +2 -2
  173. package/src/type/pixel.d.ts +0 -2
  174. package/src/type/pixel.js +3 -33
  175. package/src/type/spans.d.ts +5 -1
  176. package/src/type/spans.js +28 -5
  177. package/src/type/symbols.d.ts +1 -1
  178. package/src/type/symbols.js +20 -2
  179. package/src/type/typeset.js +25 -10
  180. package/src/version.js +1 -1
  181. package/src/color.js +0 -246
  182. package/src/effects/ascii.d.ts +0 -7
  183. package/src/effects/ascii.js +0 -93
  184. package/src/effects/buffer.d.ts +0 -60
  185. package/src/effects/buffer.js +0 -472
  186. package/src/effects/detail.d.ts +0 -12
  187. package/src/effects/detail.js +0 -124
  188. package/src/effects/digital.d.ts +0 -14
  189. package/src/effects/digital.js +0 -216
  190. package/src/effects/distort.d.ts +0 -20
  191. package/src/effects/distort.js +0 -314
  192. package/src/effects/dither.d.ts +0 -8
  193. package/src/effects/dither.js +0 -147
  194. package/src/effects/drop-shadow.d.ts +0 -4
  195. package/src/effects/drop-shadow.js +0 -45
  196. package/src/effects/extrude.d.ts +0 -5
  197. package/src/effects/extrude.js +0 -55
  198. package/src/effects/film.d.ts +0 -11
  199. package/src/effects/film.js +0 -204
  200. package/src/effects/glow.d.ts +0 -8
  201. package/src/effects/glow.js +0 -56
  202. package/src/effects/grade.d.ts +0 -30
  203. package/src/effects/grade.js +0 -274
  204. package/src/effects/halftone.d.ts +0 -8
  205. package/src/effects/halftone.js +0 -95
  206. package/src/effects/index.d.ts +0 -125
  207. package/src/effects/index.js +0 -277
  208. package/src/effects/ink.d.ts +0 -12
  209. package/src/effects/ink.js +0 -70
  210. package/src/effects/lens.d.ts +0 -14
  211. package/src/effects/lens.js +0 -277
  212. package/src/effects/light.d.ts +0 -10
  213. package/src/effects/light.js +0 -59
  214. package/src/effects/looks.d.ts +0 -9
  215. package/src/effects/looks.js +0 -74
  216. package/src/effects/mask.d.ts +0 -52
  217. package/src/effects/mask.js +0 -314
  218. package/src/effects/motion.d.ts +0 -6
  219. package/src/effects/motion.js +0 -60
  220. package/src/effects/newsprint.d.ts +0 -6
  221. package/src/effects/newsprint.js +0 -89
  222. package/src/effects/optics.d.ts +0 -10
  223. package/src/effects/optics.js +0 -213
  224. package/src/effects/options.d.ts +0 -6
  225. package/src/effects/options.js +0 -106
  226. package/src/effects/pixels.d.ts +0 -24
  227. package/src/effects/pixels.js +0 -138
  228. package/src/effects/polar.d.ts +0 -10
  229. package/src/effects/polar.js +0 -265
  230. package/src/effects/region.d.ts +0 -8
  231. package/src/effects/region.js +0 -136
  232. package/src/effects/round-border.d.ts +0 -5
  233. package/src/effects/round-border.js +0 -62
  234. package/src/effects/screen.d.ts +0 -14
  235. package/src/effects/screen.js +0 -212
  236. package/src/effects/sort.d.ts +0 -8
  237. package/src/effects/sort.js +0 -125
  238. package/src/effects/symmetry.d.ts +0 -6
  239. package/src/effects/symmetry.js +0 -74
  240. package/src/effects/tone.d.ts +0 -16
  241. package/src/effects/tone.js +0 -117
  242. package/src/effects/util.d.ts +0 -16
  243. package/src/effects/util.js +0 -85
  244. package/src/platform/bytes.d.ts +0 -22
  245. package/src/platform/bytes.js +0 -103
  246. package/src/platform/compress.d.ts +0 -12
  247. package/src/platform/compress.js +0 -81
  248. package/src/platform/node.d.ts +0 -26
  249. package/src/platform/node.js +0 -86
  250. package/src/png.js +0 -126
  251. package/src/raster/index.d.ts +0 -24
  252. package/src/raster/index.js +0 -171
  253. package/src/render/content.d.ts +0 -5
  254. package/src/render/content.js +0 -58
  255. package/src/render/tiles.d.ts +0 -6
  256. package/src/render/tiles.js +0 -77
  257. package/src/rng.js +0 -95
  258. package/src/svg.js +0 -69
  259. /package/src/{effects/paint.d.ts → paint/index.d.ts} +0 -0
package/README.md CHANGED
@@ -1,10 +1,6 @@
1
1
  # @arraypress/covers
2
2
 
3
- Cover images, flyers, posters, release art, social formats and print stationery: soft mesh gradients, film grain, riso inks and halftones, knockout and justified type, torn paper, barcodes and more, drawn from one **seed** and a stack of layers. The same slug always gives the same image, and two posts never look alike.
4
-
5
- Seventeen recipes cover the editorial looks and a brand kit (full-bleed gradient fields, engineering-blog line diagrams, cut-paper collages, risograph prints, 1-bit dithers, topographic contours, podcast waveforms, Bauhaus grids, magazine issue numbers, palettes from photos, stripe bands, an icon tile, big type, duotone photos, a single-icon mark), and every layer under them is yours to change, remove or add to. Recipes are data: the built-ins are JSON documents, and a theme can write its own cover style the same way, with no JavaScript (see [JSON recipes](#json-recipes)). Output is an SVG string (text as outlines, so it needs no fonts) or WebP / PNG / AVIF / JPEG via [sharp](https://sharp.pixelplumbing.com). Inter ships in the package, so it works with no setup. There's an Astro helper that turns a content collection into static covers in three lines.
6
-
7
- No telemetry, no DOM required. It runs in Node at build time, in a browser, and in a Cloudflare Worker (see [Running anywhere](#running-anywhere)); in Node it never touches the network unless you ask.
3
+ Cover images, flyers, posters, release art, social formats and print stationery, drawn from one seed and a stack of layers. The same slug always gives the same image. Output is SVG, or WebP / PNG / AVIF / JPEG through sharp; it runs in Node, browsers and Cloudflare Workers.
8
4
 
9
5
  ![gridTitle, light and dark](https://raw.githubusercontent.com/arraypress/covers/main/docs/gridTitle.webp)
10
6
 
@@ -35,813 +31,26 @@ const webp = await renderImage({
35
31
  await writeFile('public/covers/introducing-covers.webp', webp);
36
32
  ```
37
33
 
38
- Or build the stack yourself:
39
-
40
- ```js
41
- const svg = await renderSvg({
42
- seed: 'hello',
43
- palette: ['#0c0d12', '#3d5afe', '#ff6b3d', '#9b4dff'], // [bg, ...colors]
44
- layers: [
45
- { type: 'mesh', blur: '12vmax' },
46
- { type: 'pattern', kind: 'grid', fade: 'edges' },
47
- { type: 'grain', amount: 0.08 },
48
- { type: 'text', content: 'Hello', anchor: 'bottom-left', size: 96 },
49
- ],
50
- });
51
- ```
52
-
53
- ![a hand-built stack](https://raw.githubusercontent.com/arraypress/covers/main/docs/manual.webp)
54
-
55
- ## API
56
-
57
- | Export | |
58
- |---|---|
59
- | `cover(options)` | Resolve size and palette and expand the recipe into a layer stack. Pure, synchronous; returns plain data you can inspect or tweak. |
60
- | `renderSvg(cover)` | `Promise<string>`: an SVG document. Byte-identical for the same input. |
61
- | `renderSvg(cover, { t })` | The frame at `t` seconds, for [keyframed](#animation) layers (static covers ignore it). |
62
- | `renderImage(cover, { format, quality, lossless, effort, cache })` | `Promise<Buffer>` (Node, sharp): `'webp'` (default), `'png'`, `'avif'` or `'jpeg'`; `lossless` for WebP/AVIF (default: the recipe's preference). |
63
- | `renderBlob(cover, { format, quality, width })`, `rasterize(svg, options)` | `Promise<Blob>` in the browser: PNG, JPEG or WebP through a canvas. |
64
- | `renderPages(cover)` | `{ front, back }`: both sides of a card, ticket or flyer (see [Pages](#two-sided-pages)). |
65
- | `kit(brand, { formats, recipe })` | A whole brand kit: every format from one brand (see [Brand kit](#brand-kit)). |
66
- | `lint(cover, { target: 'spotify' })` | Release-art checks (see [lint](#lint)). |
67
- | `configure({ load, network, harfbuzz, shaper, icons, fonts })`, `registerFont(font)` | Loading and text, per platform (see [Running anywhere](#running-anywhere)). |
68
- | `FORMATS`, `KIT_FORMATS`, `safeBox(cover)` | Named formats with their safe zones (see [Formats](#formats)). |
69
- | `EFFECTS`, `EFFECT_OPTIONS`, `valueAt(keyframes, t)` | Effect kinds and their options; a keyframed value at a time. |
70
- | `coverKey(cover, options)` | The content-hash cache key (see [Caching](#caching)). |
71
- | `recipes` | The built-in recipes, by name. |
72
- | `defineRecipe(json)`, `loadRecipe(path)` | A recipe from a JSON document or file (see [JSON recipes](#json-recipes)). |
73
- | `palettes`, `resolvePalette(input)`, `derivePalette(tokens)` | Palettes (see [Palettes](#palettes)). |
74
- | `registerIcons(iconifySet)` | Use an icon set without installing its package. |
75
- | `extractPalette(image, { count })` | A photo's dominant colours (k-means in OKLab; needs sharp). |
76
- | `RISO_INKS` | Classic risograph inks by name. |
77
- | `toHex`, `toOklch`, `fromOklch`, `mixOklab`, `mixOklch`, `onBackground`, `gradientAt` | Colour helpers (`onBackground(bgs, 3, 'white')` is the icon-contrast rule). |
78
- | `createRng(seed)`, `hashString(str)` | The seeded randomness, for custom recipes. |
79
- | `resolveSize`, `ASPECTS`, `MIME`, `DEFAULT_QUALITY`, `VERSION` | |
80
-
81
- ### Cover options
82
-
83
- | Option | Default | |
84
- |---|---|---|
85
- | `seed` | the title | Drives every random choice. Use the slug. |
86
- | `width` / `height` / `aspect` | 1200×1200 | `aspect`: `'1:1'`, `'16:9'`, `'4:5'`, `'3:2'`, `'4:3'`, `'2:1'`, `'9:16'`, `'og'` (1200×630), any `'w:h'`, or a number. One side plus an aspect gives the other. |
87
- | `palette` | `'paper'` | A preset, one colour, `[bg, ...colors]`, an object, or `derivePalette()`. |
88
- | `recipe` | — | A recipe name, your own function, or a JSON recipe document. |
89
- | `layers` | mesh + grain | Without a recipe: the stack. With one: extra layers on top. |
90
- | `fonts` | Inter | `[{ name, data \| src, weight, style, axes, fallback }]`, `.ttf`/`.otf`/`.woff`/`.woff2`. `src` is a path, a URL or anything your loader reads; a variable font takes `weight: [100, 900]`; `fallback: true` draws characters other fonts lack. A JSON recipe can declare its own (`"fonts"`). Inter 400/500/600/700 is always available. |
91
- | `format` | — | A named size (`'release'`, `'instagram-story'`, `'business-card'`, …) or `{ width, height, unit: 'px' \| 'mm' \| 'in' }`. Wins over `width`/`height`/`aspect` (a screen format scales to a given `width`). See [Formats](#formats). |
92
- | `dpi`, `bleed`, `safe`, `cropMarks`, `cutLine`, `cut` | 300, 3 mm, per format | Print: resolution, bleed (mm, or `false`), safe margin (mm), crop marks in a slug, the die line. |
93
- | `overlay` | — | Draw the format's safe zones over the art, for previews. |
94
- | `page` | `'front'` | Which page of a two-sided piece to draw. |
95
- | `textRender` | `'outline'` | `'live'`: all text as `<text>` with `@font-face`. |
96
- | `loop` | — | Seconds: an animated cover's loop length. |
97
- | `alt` | the title | The SVG's `<title>`. |
98
- | `radius` | — | Rounded corners, transparent outside. |
99
- | any layer id | — | Customise a recipe's layer (see [Recipes](#recipes)). |
100
-
101
- ### Lengths
102
-
103
- Sizes are pixels as numbers, or viewport units of the cover as strings: `'6vmin'` (6% of the shorter side), `'4vw'`, `'4vh'`, `'5vmax'`. Defaults use these, so a cover keeps its proportions at any size. Positions (`position`, `region`) are fractions of the canvas, 0–1.
104
-
105
- The poster layers (`group`, `shape`, `svg`, `table`, `barcode`, `perforation`, `tear`, and `x`/`y` on `text`, `icon` and `image`) take coordinates in **pixels of their canvas**, as SVG does, or `'50%'` of it. Inside a `group` with a `viewBox`, the canvas is the group's grid: a flyer drawn on a 400×500 grid keeps its numbers at 1600×2000 or 3000×3000.
106
-
107
- ## Layers
108
-
109
- Layers paint bottom first. Every option has a default drawn from the palette, the canvas and the layer's own seeded stream, so `{ type: 'mesh' }` alone is a finished background. Every layer also takes `id` (for overrides), `seed` (its own stream), `opacity` and `blend` (a CSS `mix-blend-mode`), and:
110
-
111
- | Option | |
112
- |---|---|
113
- | `transform` | `{ translate, rotate, scale, skewX, skewY, mirror: 'x' \| 'y', origin }` about `origin` (`[x, y]` px; default the centre). Misregistration is `{ translate: [1.8, -1.3], rotate: 0.25 }`; a reflection is `{ mirror: 'y' }`. On a `text` layer, `transform: 'uppercase'` is still the case. |
114
- | `effects` | `[{ kind, … }]`: filters, pixel effects and copies, applied in order, each with an optional `mask` (see [Effects](#effects)). |
115
- | `knockout` | Layers cut out of this one: knockout type and marks, punched holes. Whatever they paint is cut, whatever colour they are. |
116
- | `clip` | Layers this one shows only inside (a photo in a circle, in type). |
117
- | `mask` | Layers whose luminance masks this one (a white-to-black `linear` fades it). |
118
- | `area` | Draw the layer in a box: `'trim'` or `'safe'` (print), or `{ x, y, width, height }` fractions. |
119
-
120
- `blend` and `opacity` go on the outermost group, so a masked or filtered layer still blends with what's under it (two riso inks overprint).
121
-
122
- A layer's random stream is keyed by its `seed`, else its `id`, else its type and position among layers of that type, so adding a layer never reshuffles the others.
123
-
124
- ### `solid`
125
- `color` (default `bg`).
126
-
127
- ### `linear`
128
- `colors` (default the first two palette colours), `opacities` (per stop), `angle` (CSS degrees, 0 = up; default seeded), `ease` (default `true`: smoothstep stops mixed in OKLab, so there's no hard edge where the gradient starts and ends).
129
-
130
- ### `radial`
131
- `colors` (centre → edge; default `[colors[0], bg]`), `x`, `y` (default seeded), `radius` (default `'80vmax'`), `ease`.
132
-
133
- ### `mesh`
134
- Full-bleed fields of blurred colour: the mesh-gradient look.
135
-
136
- | Option | Default | |
137
- |---|---|---|
138
- | `colors` | the palette's | |
139
- | `hueSpan` | — | Neighbouring hues only: keep the colours within this many degrees of OKLCH hue (blue with violet, teal with blue, orange with pink), turning the first colour's hue to make neighbours when the palette has too few. The gradient recipes use 60. |
140
- | `hues` | 3 | How many distinct colours to use. A few hues read as a colour story; every hue at once reads as a rainbow. |
141
- | `count` | 4 | Fields. |
142
- | `arrangement` | `'sweep'` | Long fields laid along one direction, their centres free to sit beyond the frame. `'ring'` spreads rounder blobs around the centre. |
143
- | `angle` | seeded | The sweep's direction, degrees. |
144
- | `streak` | 1.5 | How much more a sweep blurs along its direction than across it (broad washes rather than spots). |
145
- | `blur` | `'11vmax'` | Gaussian std deviation. |
146
- | `spread` | 1 | How far fields wander, 0–1. |
147
- | `size` | `[0.42, 0.72]` | Radius range, fractions of the longer side. |
148
- | `tone` | — | `{ l: [min, max], c: [min, max] }`: pull every colour into an OKLCH band, keeping its hue. Pastels become clear mid-tones; a dark palette can't fall to black between fields. |
149
- | `fold` | — | `true` or `{ color, to, position, bend, softness, sheen }`: a second field meeting the first along a soft curve, its edge sharper than the blur elsewhere, with a faint sheen along it (silk). |
150
- | `streaks` | 0 | Thin light motion streaks along the sweep. |
151
- | `from` | — | A photo (path or bytes): take the colours from it (needs sharp; see `extractPalette`). |
152
- | `base` | `'auto'` | Under the fields: a gradient across the sweep between the first two colours, so colour reaches every edge and corner. Or `'bg'`, a colour, or `null`. |
153
- | `blobs` | — | `[{ x, y, r, squash, rotate, color, opacity }]` to place them yourself. |
154
-
155
- ### `stripes`
156
- `direction` (`'vertical'` default, `'horizontal'`, `'diagonal'` + `angle`), `count` (8), `widths` (`'even'`, `'random'`, `'taper'` with `taper` ratio 0.78, or weights like `[3, 2, 1]`), `colors` (cycled), `gap`, `radius`, `region` (fractions; default the whole canvas), `regionRadius`.
157
-
158
- ### `image`
159
- `src` (a local path, a `data:` URI or bytes; URLs are refused), `fit` (`'cover'` / `'contain'`), `focus` (an anchor, or `'attention'` / `'entropy'` smart crops), `region`, `duotone` (`true` derives shadow and highlight from the palette, or `[shadow, highlight]`), `grayscale`, `normalize` (stretch tones first; on by default with duotone/grayscale), `tint` (a colour, or `{ color, amount, blend }`), `blur`.
160
-
161
- With sharp installed the image is cropped and resized before embedding, at the pixels it's drawn at (its box times any group and transform scale above it), never past the source's own pixels (the SVG scales it up instead). An unused alpha channel embeds as JPEG; PNG only where there's transparency. Without sharp, PNG, JPEG and SVG are embedded as they are.
162
-
163
- ### `grain`
164
- `amount` (0.08), `size` (px per grain, 1), `monochrome` (`true`), `vary` (`'edges'`: heavier toward the edges and corners, like spray paint), `blend` (`'overlay'`; also `'soft-light'`, `'multiply'`, `'screen'`, `'normal'`).
165
-
166
- ### `pattern`
167
- `kind`: `'grid'` (default), `'dots'`, `'lines'` (+ `angle`), `'rings'`, `'waves'` (+ `wavelength`, `amplitude`). `size` (cell; `'8.33vmin'`), `stroke`, `color` (`text`), `opacity` (0.14), `center` (rings and grid alignment), `fade` (`'none'`, `'radial'`, `'edges'`, `'top'`, `'bottom'`, `'left'`, `'right'`), `dash` (`'dashed'`, `'dotted'` or `[on, off]`), `divisions` (grid/lines: N equal cells, inner lines only, instead of `size`), `gaps` (regions the lines break around), `region`.
168
-
169
- Grid lines are drawn across the canvas, not as an SVG `<pattern>` (which clips a line on a tile edge to half its width), and centred.
170
-
171
- ### `shapes`
172
- `kind` (`'circle'`, `'square'`, `'pill'`, `'mixed'`), `count` (6), `size` (`['7vmin', '20vmin']`), `colors`, `placement` (`'scatter'` spreads them out avoiding overlaps; `'row'`, `'column'`), `items` (`[{ kind, x, y, size, color, rotate, opacity }]`), `region`, `rotate`, `radius` (squares, fraction of size), `outline` (`true` or a width).
173
-
174
- ### `icon`
175
- One of `name` (Iconify: `'lucide:feather'`, `'simple-icons:github'`), `svg` (a raw SVG string; scripts and handlers are stripped) or `path` (+ `viewBox`). `size` (`'24vmin'`, `'15vmin'` on a tile), `color` (the accent on a tile, else `text`), `fill` (a gradient, tile or preset [paint](#paints) instead), `strokeWidth`, `anchor`, `position`, `offset`, `margin`, and `tile` (`true` or `{ size, radius, color, shadow: 'soft' | 'none', border }`).
176
-
177
- Named icons load from `@iconify-json/<prefix>` (resolved from your project), or from a set you pass to `registerIcons()`.
178
-
179
- ### `text`
180
- Set as outlines, so the SVG needs no fonts.
181
-
182
- | Option | Default | |
183
- |---|---|---|
184
- | `content` | — | `\n` breaks a line. |
185
- | `font`, `weight`, `style` | `'Inter'`, 600 | The closest weight in the family wins. |
186
- | `size` | 6% of `sqrt(w × h)` | |
187
- | `minSize` | — | Shrink toward this before truncating. |
188
- | `lineHeight` | 1.12 | |
189
- | `letterSpacing` | -0.02 | Em. Tighten big type further (`-0.045`). |
190
- | `color` | `text` | |
191
- | `anchor` | `'bottom-left'` | Nine anchors, inside the safe `margin` (7% of `sqrt(w × h)`). |
192
- | `align` | from the anchor | |
193
- | `position`, `offset` | — | |
194
- | `maxWidth` | width less margins | |
195
- | `maxLines` | 3 | Past it, cut at a word with `ellipsis` (`'…'`). |
196
- | `balance` | `true` | Even line lengths (CSS `text-wrap: balance`). |
197
- | `transform` | — | `'uppercase'` / `'lowercase'`. |
198
- | `label` | — | A small line above: a string, or `{ content, size, weight, color, letterSpacing, transform, gap }` (uppercase, tracked, `muted` by default). |
199
- | `background` | — | A pill or box behind: a colour, or `{ color, radius, padding, opacity }`. |
200
-
201
- Text is shaped by [HarfBuzz](https://harfbuzz.github.io) (kerning, ligatures, any script the font covers) and trimmed to the cap height above and the baseline below, so a margin measured to a headline is the margin you see. A word wider than the line breaks between characters. Pass a font that covers your script for non-Latin titles (the bundled Inter is a Latin subset).
202
-
203
- ### `diagram`
204
- White line-art: one icon, or nodes (icons in outlined tiles) joined by arrows.
205
-
206
- | Option | Default | |
207
- |---|---|---|
208
- | `icon` | — | One icon, drawn large (an Iconify name or raw SVG). |
209
- | `nodes` | — | `[{ icon, shape: 'square' \| 'circle' \| 'none', size, brackets }]`. |
210
- | `layout` | from the count | `'single'`, `'row'` (2–4), `'triangle'` (one over two), `'square'` (four corners, a fifth in the middle with dotted spokes and corner brackets), `'stack'` (two merging into one), `'hub'` (a large first node with the rest round its edge, knocked out of it: a globe with databases). |
211
- | `arrows` | `true` | `false`, `'loop'` (row: an elbow back from the last to the first), `'both'` (triangle: there and back), `'cycle'` (square). |
212
- | `size`, `gap` | per layout | Node size and spacing. |
213
- | `stroke` | `'0.3vmin'` | One line weight for tiles, arrows and icons (Lucide strokes are rescaled to match). |
214
- | `color` | white | |
215
- | `grid` | — | `true` or `{ divisions: 4, opacity: 0.6, stroke, color, pad }`: a dotted grid whose lines break around the nodes and arrows instead of running through them. |
216
- | `iconScale`, `radius`, `position` | 0.44, — , centre | |
217
-
218
- ### `paper`
219
- Cut-paper shapes: the outline is resampled into short straight cuts, each nudged off the line (seeded), so edges are irregular but never blurry. `count` (4, laid out as a loose collage), `kind` (`'mixed'`, `'rect'`, `'square'`, `'circle'`, `'blob'`, `'arch'`, `'pill'`), `color` (`#faf9f5`), `region` (the middle 60%), `items` (`[{ kind, x, y, width, height, size, rotate, color }]`), `wobble` (0.5).
220
-
221
- ### `scribble`
222
- A hand-drawn line: an `icon` re-drawn by hand (its outline flattened, wobbled with smooth seeded noise, stroked in short runs whose width drifts, round caps), or `graph` (`true` or `{ nodes, dot }`: a polyline with filled dots). `color` (`#faf9f5`), `stroke` (`'1.3vmin'`), `size` (`'46vmin'`), `position`, `wobble` (0.5).
223
-
224
- ### `ink`
225
- One risograph ink: flat `shapes` (`circle`, `ring`, `half`, `arch`, `band`, `rect`, or `text` with any text-layer options), printed with `multiply` (`screen` on dark paper) and slightly misregistered (`offset`, default seeded 0.3–1% of the short side). `color` is a colour or a classic ink by name: `'riso:pink'`, `blue`, `yellow`, `teal`, `red`, `green`, `orange`, `purple`, `navy`, `black` (`RISO_INKS`, approximations). `texture`: `'grain'` (speckle where the paper shows through; `speckle` 0.09), `'halftone'` (`cell`, `dot`, `angle` 0 or 45) or `'none'`. `density` (`[0.78, 1]`): the ink thins across the sheet.
226
-
227
- ### `dither`
228
- A soft field rendered 1-bit at a visible pixel size (`cell`, `'0.9vmin'`): `source` (`'field'` noise, `'linear'`, `'radial'`), an optional `shape` (`'sphere'` lit from the upper left, `'circle'`, `'arch'`; `size`, `center`), `matrix` (`'bayer4'`, `'bayer8'`, `'noise'`), `colors` (`[ground, ink]`, or three for two dithered bands), `gamma`, `range`. It's a tiny PNG (one pixel per cell) drawn with `image-rendering: pixelated`, so the cells stay crisp at any size and the SVG stays small.
229
-
230
- ### `contour`
231
- Topographic isolines of seeded noise (marching squares, joined and smoothed): `levels` (20), `color` and `stroke`, `opacity` (0.5), `index` (every Nth line thicker, 5), `highlight` (one level in the accent; seeded, or a number, or `false`), `scale`, `octaves`, `resolution`.
232
-
233
- ### `waveform`
234
- An episode's waveform: `peaks` (any scale), or `audio` (a file decoded at build with [`@arraypress/waveform-gen`](https://www.npmjs.com/package/@arraypress/waveform-gen), an optional peer). `style` (`'mirrored'`, `'bars'`, `'line'`, `'dots'`), `bars` (88), `gap`, `round`, `color`, `region`, `caption` (`true` for the duration, from the audio or `duration`, or any text). **With neither peaks nor audio it draws seeded demo peaks** — a placeholder, not your audio.
235
-
236
- ### `tiles`
237
- A Bauhaus grid: each cell a ground with one shape (`quarter`, `half`, `circle`, `square`, `triangle`), rotated by quarters, in `colors` plus `ground`. `series` seeds the grid's rhythm (columns, rows and a merged 2×2 cell), so covers in a series share a family look while each cover's seed fills the cells. `cols`, `rows`, `kinds`, `merge`, `region`.
238
-
239
- ### `rule`
240
- A hairline: `y` (fraction), or `from` / `to` points; `inset`, `color`, `stroke`.
241
-
242
- ### `frame`
243
- `inset` (`'3.5vmin'`), `radius` (`'2vmin'`), `stroke` (`'0.15vmin'`), `color` (`text`, at opacity 0.22), `fill` (a colour, to make a card).
244
-
245
- ### Poster type (`text`, 1.1)
246
- The `text` layer takes these as well. Any of them switches it to the poster typesetter (1.0 text is unchanged, byte for byte).
247
-
248
- | Option | |
249
- |---|---|
250
- | `x`, `y` | The first baseline, like SVG `<text x y>`; `align` acts as the anchor (`'right'` sets the end at `x`). No wrapping unless `maxWidth` is given. |
251
- | `width` | Justify every line to exactly this width (`textLength`). `lengthAdjust: 'spacing'` opens the letter-spacing instead of scaling the glyphs. `justify: true` uses `maxWidth`. |
252
- | `fit` | `'width'`: size the block so its widest line fills `width`; `'lines'`: size every line on its own to fill it (the stacked full-width headline); `'words'`: a word a line, the widest filling `width`; `'wrap'`: wrap at the width, then size every line so the widest fills it; `'shrink'`: shrink before wrapping. `split: 'words'` puts a word on each line without fitting; `maxHeight` shrinks a block taller than it. |
253
- | `spans` | `[{ content, font, weight, style, axes, scale, size, color, letterSpacing, dy }]` instead of `content`: mixed runs on the same lines (a `\n` starts a new one). |
254
- | `glyphs` | Per-glyph arrays, cycled and counted across the block (spaces skipped): `scale`, `scaleX`, `scaleY`, `dx`, `dy`, `rotate`, `color`; `origin: 'center' \| 'baseline'`; a scaled glyph takes its scaled width unless `advance: false`. Bulging 70s type is one layer. |
255
- | `font: 'pixel'` | A built-in 5×7 pixel face (A–Z, 0–9, punctuation), drawn as merged cells; everything else here works on it. |
256
- | `fallback` | A family (or list) for characters the font lacks; then fonts registered with `fallback: true`; then built-in shapes for ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪. |
257
- | `extrude` | A depth, or `{ depth, angle, color, to, steps }`: block extrusion (the [`extrude`](#effects) effect). |
258
- | `dash` | Dashes on `stroke` (and `dash` on each of `strokes`): stitching. |
259
- | `stretch` | `[sx, sy]` (or `sy`) about the first baseline: condensed and stretched display type. |
260
- | `axes` | Variable-font axes: `{ wdth: 50 }`, `{ SOFT: 100, WONK: 1, opsz: 144 }`, `{ YEAR: 1979 }`. A variable font's `wght` follows `weight`. |
261
- | `content` | An array is one line per item. |
262
- | `fill`, `stroke`, `strokeWidth` | `fill: 'none'` with a `stroke` is outline type; `fill` takes gradients. |
263
- | `strokes` | `[{ color, width, dash }]`: keylines under the fill, widest first (varsity lettering). A preset paint (`fill: { preset: 'chrome' }`) brings its own. |
264
- | `path` | Text on a path: `{ kind: 'circle' \| 'arc' \| 'ellipse' \| 'wave', cx, cy, r, start, end, direction }` or `{ d }`. `startOffset` (0–1 or px; 0.5 centres), `fitPath` (space it round a ring; a length leaves a gap), `dy`. |
265
- | `color: 'auto'` | White or near-black by what's drawn underneath (rendered small and averaged: sharp in Node, a canvas in a browser, the background colour in a Worker): luma (Rec. 601) above 0.72 takes the dark colour, as designers set it (a mid pink takes white). `autoColors: [light, dark]`, `autoThreshold`. |
266
- | `{layout.<id>.<field>}` | Any later layer can place itself by where a text block landed: `bottom`, `top`, `left`, `right`, `width`, `height`, `x`, `y`, `baseline`, `size`, `lines`, plus or minus a number (`"y": "{layout.title.bottom + 12}"`), in the text layer's units. |
267
- | `render: 'live'` | `<text>` with the font embedded by `@font-face` (or linked by URL when it came from one): selectable, fast in a browser, laid out the same. |
268
-
269
- ### Poster layers
270
-
271
- Coordinates in px of the layer's canvas (or its group's `viewBox`), or `'50%'`. Every option is documented in the layer's module (`src/layers/*.js`).
272
-
273
- | Layer | Draws | Main options |
274
- |---|---|---|
275
- | `group` | A nested stack on its own design grid | `layers`, `viewBox`, `x` `y` `width` `height`, `fit`, `align` |
276
- | `shape` | One exact shape, or `items` | `kind` (rect, circle, ellipse, line, polygon, polyline, star, burst, scallop, rays, ring, sector, arch, wave, path, `cut`: the print format's die-cut, `outset`), `fill`/`stroke` ([paints](#paints)), `edge` (deckle, torn, zigzag, wave, scallop), `strokes`, `dash`, `dashOffset`, `rotate` (about the centre, or `origin`), `open` (an arch as an arc), `soft` (a blur drawn padded: no edge fade, no square edge) |
277
- | `repeat` | A layer many times | `layer` (or `layers`); a grid (`rows`, `cols`, `step`), a ring (`count`, `cx`, `cy`, `radius`, `start`, `sweep`, `turn`) or a line (`count`, `step`). `{#…}` expressions per instance (`i`, `n`, `row`, `col`, `x`, `y`, `angle`, `odd`; `+ - * / %`, `? :`, `min` `max` `abs` `floor` `round` `sin` `cos` `pick`); `odd` and `cycle` overrides; `skip` |
278
- | `defs`, `use` | A named stack drawn again | `defs.layers` (each with an `id`; nothing drawn); `use`: `ref`, `x`, `y`, `rotate`, `scale` (or `place`, a transform), `with` (overrides for that copy). A plain use is the same markup referenced |
279
- | `svg` | Raw markup: the escape hatch | `markup`, `defs`, `viewBox`, `fit`; tokens `{{@accent}}`, `{{id}}`, `{{seed}}`, and `{{name}}` slots in JSON recipes (a markup value such as `{{mark}}` between elements goes in as sanitised shapes; anything else is escaped). Scripts, handlers and links out are stripped |
280
- | `table` | Meta grids, lineups, tracklists | `rows`, `columns` (`x`, `align`, `key`, style), `x`, `y`, `size`, `rowHeight`, `header`, `rules` |
281
- | `barcode` | Real Code 128, or decorative bars | `value`, `kind`, `x` `y` `width` `height`, `text` |
282
- | `perforation` | A tear-off line | `kind` (holes, dashes, notches), `x1` `y1` `x2` `y2`, `size`, `gap`; punches holes inside a `knockout` |
283
- | `tear` | Paper torn on a jagged line | `from`, `to`, `side`, `color`, `rip`, `fibres`, `step`, `jitter` (same `seed` on two layers: one tear) |
284
- | `texture` | Surfaces | `kind` (paper, creases, concrete, speck, wood, folds, dust), `color`, `frequency`, `region` |
285
-
286
- `icon` also takes `markup` (a mark's paths on a 512 grid) and `x`, `y`; `image` takes `x` `y` `width` `height`, `fit: 'stretch'` and URLs where fetching is allowed. A cut-out subject is another `image` above the masthead.
287
-
288
- ## Effects
289
-
290
- `effects: [{ kind, … }]` on any layer or group, applied in order (`src/effects/`, one module per family). Most are SVG filters (vector, sized in the layer's units); the ones filters can't do are computed on pixels (◆): the layer is drawn once at the pixels it's shown at (sharp in Node, a canvas in the browser), processed on a typed array and embedded as one image. Copies (▲) redraw the layer's own markup. Lengths are px or `'vmin'` strings; points (`center`, `at`) are `[x, y]` fractions.
291
-
292
- | Kind | Options | |
293
- |---|---|---|
294
- | **Blur** | | |
295
- | `blur` | `radius`, or `x`, `y`; `edge: 'duplicate'` | Gaussian. |
296
- | `motion` | `angle`, `length` (σ along), `spread` | Directional blur at any angle (on pixels in `renderImage`: librsvg can't blur along a turned axis). |
297
- | `zoom` ◆ | `center`, `amount` (0–0.95) | Radial: each point smeared toward the centre. |
298
- | `spin` ◆ | `center`, `angle` | Rotational blur round a centre; the centre stays sharp. |
299
- | `tilt` ◆ | `center`, `angle`, `width`, `falloff`, `radius`, `saturate` | Tilt-shift: a sharp band, the blur growing beyond it. |
300
- | `lens` ◆ | `radius`, `blades` (0 round, 5–8), `rotation`, `highlights`, `threshold` | Lens blur: highlights become bokeh discs or polygons. |
301
- | **Distort** | | |
302
- | `wave` ◆ | `amplitude`, `wavelength`, `angle`, `phase`, `shape` (`sine`, `triangle`, `square`) | |
303
- | `ripple` ◆ | `center`, `amplitude`, `wavelength`, `phase`, `radius` | Rings on water. |
304
- | `twirl` ◆ | `center`, `angle`, `radius` | Swirl. |
305
- | `bulge`, `pinch` ◆ | `center`, `amount`, `radius` | |
306
- | `fisheye` ◆ | `center`, `amount` | Barrel distortion over the frame. |
307
- | `glass` ◆ | `texture` (`frosted`, `reeded`, `pebbled`), `size`, `amount`, `angle`, `shade`, `seed` | Through frosted, fluted or pebbled glass. |
308
- | `displace` ◆ | `amount`, `map: { layers }` (else seeded noise: `size`, `octaves`, `seed`), `mode` (`gradient`, `channels`) | Displacement by a texture, a photo or type. |
309
- | `kaleidoscope` ◆ | `segments`, `center`, `angle`, `scale` | |
310
- | `mirror` ▲ | `keep` (`left`, `top`, `top-left`, …), `at` | One half (or quarter) reflected. |
311
- | `stretch` ◆ | `at`, `direction`, `length`, `fade` | Pixel stretch: one row or column smeared to the edge. |
312
- | `sort` ◆ | `direction`, `threshold` (`[lo, hi]`), `by` (`luma`, `hue`, `saturation`), `reverse`, `length`, `seed` | Pixel sorting. |
313
- | **Colour** | | |
314
- | `curves` | `points`, `red`, `green`, `blue` (`[[in, out], …]`, 0–1) | Smooth tone curve, per channel too. |
315
- | `levels`, `grayscale` | `black`, `white`, `gamma`, `output`; or `slope`, `intercept` (`contrast`, `brightness`) | |
316
- | `gradientMap` | `colors`, `stops`, `space` (`srgb`, `oklab`) | Tones through a multi-stop gradient. |
317
- | `duotone` | `colors`, `contrast` | |
318
- | `posterize` | `levels` | |
319
- | `threshold` | `level`, `ink`, `paper` | Two colours. |
320
- | `invert` | `amount` | |
321
- | `hue` | `angle`, `saturation`, `lightness` | Hue/saturation. |
322
- | `saturate`, `vibrance` ◆ | `amount`; `amount`, `saturation` | Vibrance lifts muted colours, holds skin. |
323
- | `colorBalance` | `shadows`, `midtones`, `highlights` (`[r, g, b]`, −1–1) | |
324
- | `channelMixer` | `red`, `green`, `blue` (`[r, g, b, k]`), `monochrome`, `gray` | |
325
- | `sepia`, `matrix` | `amount`; `values` | |
326
- | `look` | `name` (`fade`, `crossProcess`, `bleachBypass`, `tealOrange`, `warmFilm`, `coolFilm`, `noir`), `amount` | Graded presets. |
327
- | **Light** | | |
328
- | `bloom` | `threshold`, `radius`, `intensity`, `color` | Highlights glowing (spreads past the layer: neon type). |
329
- | `halation` | `threshold`, `radius`, `intensity`, `color` | Film's red halo round highlights. |
330
- | `glow`, `shadow` | `x`, `y`, `blur`, `color`, `opacity`, `spread` | A halo or drop shadow from the alpha. |
331
- | `vignette` | `shape` (`ellipse`, `circle`, `rect`), `size`, `softness`, `amount`, `color`, `center` | |
332
- | `lightLeak` | `edge`, `colors`, `intensity`, `seed` | Warm light fogging an edge. |
333
- | `lensFlare` | `at`, `size`, `intensity`, `color`, `ghosts`, `streak`, `seed` | Soft glow, halo, ghosts, optional anamorphic streak. |
334
- | `rays` ◆ | `center` (default the brightest patch), `threshold`, `length`, `intensity`, `color` | God rays: occluders cast shafts. |
335
- | **Texture and print** | | |
336
- | `grain` | `amount`, `size`, `color`, `seed` | Film grain, strongest in the midtones. |
337
- | `dust` ◆ | `amount`, `scratches`, `color` (`light`, `dark`, `mixed`), `seed` | Dust, hairs and scratches. |
338
- | `paper` | `texture` (`paper`, `canvas`), `scale`, `amount`, `angle` | Printed on paper or canvas. |
339
- | `scanlines` | `spacing`, `thickness`, `amount` | |
340
- | `crt` ◆ | `mask` (`aperture`, `shadow`), `size`, `amount`, `scanlines`, `curvature` | Phosphor RGB mask. |
341
- | `vhs` ◆ | `shift`, `bleed`, `jitter`, `tracking`, `noise`, `seed` | Chroma smear and shift, line jitter, tracking noise. |
342
- | `jpeg` ◆ | `quality`, `block`, `passes`, `subsample` | Real JPEG compression, blocks at any size. |
343
- | `halftone` | `ink`, `cell`, `angle`, `contrast`, `lift`, `softness`, `shape` (`dot`, `line`, `square`, `diamond`, `cross`) | One-ink screen (two layers, `multiply`, a `transform`: a two-ink overprint). |
344
- | `newsprint` ◆ | `cell`, `angles` (`[c, m, y, k]`), `paper`, `gain`, `gcr` | CMYK process halftone, rosettes. |
345
- | `dither` | `ink`, `paper`, `cell`, `contrast`, `brightness`, `pixelate`, `weights`, `matrix` | Ordered 1-bit. |
346
- | `wear`, `xerox`, `photocopy`, `grit` | `rough`, `uneven`, `specks`; `rough`, `dropout`; `ink`, `contrast`, `lift`, `noise`; `amount`, `frequency` | Riso drum wear, copier toner. |
347
- | `rough`, `warp` | `scale`, `frequency`, `octaves` | Displacement: a wobble, a melt. |
348
- | **Digital** | | |
349
- | `chromatic`, `rgbSplit` | `amount`, `angle`, `mode` (`directional`, `radial` ◆), `center` | Chromatic aberration. |
350
- | `glitch` ◆ | `amount`, `shift`, `slices`, `blocks`, `seed` | RGB shift, torn slices, displaced blocks. |
351
- | `pixelate`, `mosaic` ◆ | `size`, `shape` (`square`, `hex`) | |
352
- | `ascii` ◆ | `cell`, `ramp`, `color` (`source` or a colour), `background`, `invert`, `contrast` | |
353
- | `edges` | `radius`, `amount`, `style` (`ink`, `glow`), `color`, `mono` | Find edges, glowing edges. |
354
- | `sketch` | `radius`, `darkness`, `color` | Pencil drawing. |
355
- | `emboss` | `angle`, `amount`, `radius`, `color` | |
356
- | `sharpen` | `amount`, `radius` | Unsharp mask. |
357
- | **Other** | | |
358
- | `border` | `width`, `color`, `line`, `lineWidth`, `shape: 'circle'` | Die-cut sticker border, or a round backing. |
359
- | `extrude` ▲ | `depth`, `angle`, `offset`, `steps`, `color`, `to` | Block extrusion. |
360
- | `filter` | `markup` | Raw primitives (`{{seed}}`, `{{id}}`). |
361
-
362
- **`mask`, on any effect**: where it applies, from none to full. `{ gradient: 'linear', from: [x, y], to: [x, y] }`, `{ gradient: 'radial', center, from, to, aspect }` (radii), `{ gradient: 'band', center, angle, from, to }` (distances from a line), or `{ layers: [...] }` (their luminance); each takes `invert` and `ease`. The mix is exact: inside the filter, or per pixel. "The left half motion-blurred, the face sharp" is `{ kind: 'motion', length: '6vmin', mask: { gradient: 'linear', from: [0.4, 0.5], to: [0.25, 0.5] } }`.
363
-
364
- Every effect also takes `colorSpace` (`'sRGB'`, the default, or `'linearRGB'`) and `pad`. Numbers are in the layer's units: inside a group, its grid's, so an effect looks the same at every output size. Random ones take a `seed` (else the layer's stream). JSON recipes are validated kind by kind (`EFFECT_OPTIONS`). Where nothing can draw pixels (a Worker), `lens` falls back to a Gaussian blur, `tilt` to one through its band, and the other ◆ effects are left out.
365
-
366
- ### Paints
367
-
368
- `fill` and `stroke` on shapes, text and icons:
369
-
370
- | Paint | |
371
- |---|---|
372
- | A colour, `'none'` | |
373
- | `{ type: 'linear', colors, stops, opacities, angle, spread, length }` | Over the shape's bounds (a text block's whole box); `spread: 'reflect'` with a `length` is foil. |
374
- | `{ type: 'radial', colors, stops, opacities, cx, cy, r, rx, ry, fx, fy }` | `rx`/`ry` (lengths, or fractions of the box) make it elliptical. |
375
- | `{ type: 'tile', kind, size, colors, ratio, angle, markup }` | A pattern from the shape's corner: `checker`, `stripes`, `dots`, `grid`, or a `custom` tile of markup. |
376
- | `{ preset: 'chrome' \| 'steel' \| 'gold', … }` | Y2K metal as it's painted: sky, a sharp dark horizon at ~55%, warm ground, a light bounce; with its keyline (`keyline: false` for none). Fields override it. |
377
-
378
- ## Formats
379
-
380
- `format` sets the size from a name (`src/formats/presets.js`). Screen formats carry their **safe zone**: anchored text and icons are laid out inside it, recipes read it as `@safeX` `@safeY` `@safeWidth` `@safeHeight`, and `overlay: true` draws the zones (safe green, covered red, crops blue).
381
-
382
- | Format | Size | Safe zone and where it comes from |
383
- |---|---|---|
384
- | `release` | 3000×3000 | Release cover art (Spotify, Apple Music, Bandcamp). See [lint](#lint). |
385
- | `spotify-canvas` | 1080×1920 | Central; the player covers the bottom third (measured). Canvas should carry no text. |
386
- | `bandcamp-header` | 975×180 | Bandcamp's custom header width (40–180 tall). |
387
- | `instagram-post` | 1080×1350 | The central square (some previews crop to it). |
388
- | `instagram-square` | 1080×1080 | |
389
- | `instagram-story` | 1080×1920 | Meta: keep ~14% (250 px) top and bottom free of text and logos. |
390
- | `instagram-reel` | 1080×1920 | Meta: 14% top, 35% bottom, 6% sides; the 3:4 grid crop. |
391
- | `tiktok-cover` | 1080×1920 | Commonly measured margins (240 top, ~600 bottom, 120 right) and the 3:4 grid crop; TikTok publishes its zones only as template files. |
392
- | `youtube-thumbnail` | 1280×720 | YouTube Help size; the duration badge bottom right. |
393
- | `youtube-banner` | 2560×1440 | YouTube Help: keep text and logos in the centre 1235×338 of a 2048×1152 upload (1546×423 here); TV, desktop, tablet and mobile crops. |
394
- | `x-header` | 1500×500 | The avatar over the bottom left; ~60 px trimmed top and bottom (community measurements; X publishes none). |
395
- | `linkedin-banner`, `linkedin-company` | 1584×396, 1128×191 | LinkedIn's sizes; the profile photo over the banner's bottom left. |
396
- | `facebook-cover` | 1640×924 | Meta Help: shows at 820×312 on desktop and 640×360 on phones; the desktop band in the middle, the profile picture bottom left. |
397
- | `twitch-banner`, `discord-banner`, `discord-profile`, `bluesky-banner` | 1200×480, 960×540, 680×240, 3000×1000 | The platforms' recommended sizes; avatars over the profile banners. |
398
- | `pinterest` | 1000×1500 | Pinterest's 2:3; the save button bottom right. |
399
- | `avatar`, `x-avatar`, `youtube-avatar` | 1080², 400², 800² | Every platform crops avatars to a circle (X, Instagram, Threads, LinkedIn, YouTube, TikTok, Bluesky); the safe square is inscribed in it. |
400
- | `og` | 1200×630 | |
401
- | `flyer` | 1600×2000 | A digital 4:5 flyer. |
402
- | `a6`, `a5`, `a4`, `a3`, `a2` | mm | Flyers and posters. |
403
- | `business-card`, `business-card-us` | 85×55 mm, 3.5×2 in | |
404
- | `letterhead`, `letterhead-us` | A4, US Letter | |
405
- | `compliment-slip` | 210×99 mm | |
406
- | `sticker`, `sticker-square` | 60 mm | Die-cut round, rounded square. |
407
- | `ticket` | 5.5×2 in | |
408
- | `badge`, `lanyard-pass` | 54×85.6 mm (CR80), A6 | |
409
-
410
- A custom format is `{ width, height, unit: 'px' | 'mm' | 'in', bleed, safe, zones }`. A screen format given a `width` scales (its zones too).
411
-
412
- ### Print, pages
413
-
414
- | Option | |
415
- |---|---|
416
- | `dpi` | 300. Print sizes are trim sizes in mm or inches. |
417
- | `bleed`, `safe` | mm (default per format, bleed 3). The canvas is the bleed box; anchored text sits inside the safe margin. Tokens `@bleed` `@safe` `@trimWidth` `@trimHeight` `@mm` `@dpi`; `area: 'trim' \| 'safe'`. |
418
- | `cropMarks`, `cutLine` | Crop marks in a slug; the die line (`CutContour`). `renderImage` writes the dpi. |
419
- | `cut` | The die-cut: `'rounded'` (3 mm), `'circle'`, `{ radius }` (mm), or `{ d, viewBox }` (a path drawn into the trim). Tickets and lanyard passes have rounded corners by default; a `shape` of `kind: 'cut'` draws it (as a fill, or a `clip`). |
420
- | `page` | A recipe's `"pages": ["front", "back"]` with `when: { "page": "back" }`; `renderPages(cover)` returns `{ front, back }`. |
421
-
422
- Artwork on objects (a card on a desk, a lanyard on wood) is a mockups library, not covers.
423
-
424
- ## Brand kit
425
-
426
- ```js
427
- const files = await kit({ name: 'Hollow Fern', tagline: 'Field recordings', palette: { accent: '#2f6b4f' }, mark, lines, person: 'Mira Okafor' }, { formats: ['avatar', 'x-header', 'business-card'] });
428
- files['business-card'].back; // SVG
429
- ```
430
-
431
- `kit(brand, { formats, recipe, pages, render, overlay })`: every format in `KIT_FORMATS` by default, with the built-in `brand` recipe (or one per format). Two-sided formats return `{ front, back }`.
432
-
433
- ## lint
434
-
435
- `lint(cover, { target: 'spotify', artist, title })` → `{ ok, issues: [{ rule, severity, message, text }] }`. Spotify's own rules: square, 640–10000 px, not upscaled (3000×3000 is what stores ask for). The content rules are distributors' (they reject art that breaks them).
436
-
437
- | Rule | Severity | |
438
- |---|---|---|
439
- | `aspect`, `resolution` | error (warning under 3000) | |
440
- | `url`, `handle`, `contact`, `price`, `promo`, `platform`, `barcode` | error | Addresses, @handles, email/phone, prices and "free", "out now", store names (a name that's also a word, "Tidal", only with a cue: "on Tidal", or on its own), barcodes |
441
- | `date`, `physical` | warning | Dates; day names spelled out, or short ones beside a number ("Sat 14", not "Second Sun"); CD, vinyl |
442
- | `legibility` | warning | The block's smallest capitals under 4 px at a 64 px thumbnail, measured as drawn (every line, stretch, justification, group and transform scale) |
443
- | `upscaled`, `metadata` | warning | An image drawn bigger than its pixels (through groups and transforms), or embedded smaller than drawn; words not in `artist`/`title` (a letter per layer or a knocked-out word spelled from them passes) |
444
-
445
- ## Running anywhere
34
+ ## What's in it
446
35
 
447
- No module imports `node:`; `renderSvg`, `renderPages`, `kit` and `lint` run in Node, browsers and Cloudflare Workers (`src/platform/`).
36
+ - [API](https://github.com/arraypress/covers/blob/main/docs/api.md): every export and cover option
37
+ - [Layers](https://github.com/arraypress/covers/blob/main/docs/layers.md): mesh, grain, text, diagrams, riso inks, dithers, poster type and more
38
+ - [Effects](https://github.com/arraypress/covers/blob/main/docs/effects.md): filters and pixel effects on any layer, masks, paints
39
+ - [Recipes](https://github.com/arraypress/covers/blob/main/docs/recipes.md): seventeen built-ins, 87 flyers and release covers, your own
40
+ - [JSON recipes](https://github.com/arraypress/covers/blob/main/docs/json-recipes.md): a cover style as data, with no JavaScript
41
+ - [Palettes](https://github.com/arraypress/covers/blob/main/docs/palettes.md): presets, one accent, a theme's tokens
42
+ - [Formats, print and brand kits](https://github.com/arraypress/covers/blob/main/docs/formats.md): social sizes with safe zones, print with bleed, `kit`, `lint`
43
+ - [Animation](https://github.com/arraypress/covers/blob/main/docs/animation.md): motion in the SVG itself
44
+ - [Running anywhere](https://github.com/arraypress/covers/blob/main/docs/platforms.md): Node, browsers, Workers
45
+ - [Astro](https://github.com/arraypress/covers/blob/main/docs/astro.md): static covers for a content collection
46
+ - [Caching and performance](https://github.com/arraypress/covers/blob/main/docs/performance.md)
448
47
 
449
- | | Node | Browser | Cloudflare Worker |
450
- |---|---|---|---|
451
- | Text | HarfBuzz | HarfBuzz (wasm) | A JavaScript TrueType reader, byte-identical to HarfBuzz on the 1.0 test set (no ligatures, CFF or variable instances) |
452
- | Files | Paths; URLs with `network: true` | URLs, fetched | URLs, fetched |
453
- | Raster | `renderImage` (sharp) | `renderBlob`, `rasterize` (canvas) | Return the SVG |
454
- | `color: 'auto'` | sharp | canvas | The background colour |
455
-
456
- ```js
457
- configure({
458
- harfbuzz: '/assets/harfbuzz.wasm', // URL, bytes, WebAssembly.Module or Response
459
- icons: 'https://cdn.jsdelivr.net/npm/@iconify-json/{prefix}/icons.json',
460
- load: (src, { kind }) => assets.get(src), // return bytes, or undefined to fall through
461
- network: true, // let Node fetch URLs
462
- fonts: [{ name: 'Fraunces', src: '/fonts/Fraunces.ttf', weight: [100, 900] }],
463
- });
464
- ```
465
-
466
- Workers resolve `#harfbuzz` to a stub (harfbuzzjs compiles wasm at runtime, which Workers forbid). `@arraypress/covers/node` installs Node's modules up front (Node before 20.16). WOFF2 needs Brotli: Node has it, and browsers with `DecompressionStream('brotli')`; elsewhere (most Workers) convert it first with `node scripts/woff2-to-ttf.mjs font.woff2` (it uses this library's decoder, or fontTools / `woff2_decompress` when present), or use `render: 'live'`.
467
-
468
- ## Animation
469
-
470
- Designed in, not built yet: any option can be `{ "keyframes": [[t, value], …], "ease", "loop" }` (seconds), and `renderSvg(cover, { t })` draws that frame; static covers ignore `t`. Numbers, colours (OKLab) and same-unit lengths interpolate.
471
-
472
- The video phase: browser frames to WebCodecs (`VideoEncoder`, MP4/WebM) with `MediaRecorder` as the fallback; Node frames from sharp piped to ffmpeg (`@arraypress/video-utils` parses and embeds video URLs but has no encoder, so the runner lands there or beside it). CSS-animated SVG suits avatars and banners on websites (tiny, sharp, `prefers-reduced-motion`), but only for what CSS animates (transforms, opacity, colour); social platforms take video.
473
-
474
- ## Recipes
475
-
476
- A recipe returns a stack of layers with ids. Customise any of them by id:
477
-
478
- - a **string** is the layer's content: `title: 'Hello'`, `icon: 'lucide:feather'`
479
- - an **object** is merged into the layer: `mesh: { blur: '16vmax' }`, `title: { content: 'Hello', size: 96 }`
480
- - **`false`** removes it: `grain: false`
481
-
482
- ```js
483
- await renderImage({
484
- recipe: 'gridTitle',
485
- seed: post.slug,
486
- title: post.title,
487
- mesh: { hues: 2, blur: '14vmax' },
488
- grid: { kind: 'dots' },
489
- layers: [{ type: 'frame' }], // painted on top
490
- });
491
- ```
492
-
493
- Every recipe takes an optional `title` and `label`.
494
-
495
- ### `aurora`
496
- Layers `bg`, `mesh`, `grain` (+ `title`). Full-bleed sweeps of the palette's colours; per seed, sometimes a silk fold, sometimes motion streaks, sometimes grain heavier at the edges.
497
-
498
- ![aurora](https://raw.githubusercontent.com/arraypress/covers/main/docs/aurora.webp)
499
-
500
- ### `gridTitle`
501
- Layers `bg`, `mesh`, `grid`, `grain` (+ `title`). The same field under a dotted grid of four cells each way.
502
-
503
- Both were tuned against openai.com/news, measured rather than eyeballed: colour reaches every edge and corner (no page colour, no vignette), two or three hues in fields larger than the frame, directional sweeps and folds rather than round blobs, clear mid-tones (OKLCH lightness ~0.55–0.85, chroma ~0.1–0.2), and **no title on the image** (the page sets it under the card). Give a `title` and one sets lower left: the field is then toned so the palette's text clears 4.5:1 wherever the title sits (light pages: lightness 0.66–0.82; dark: 0.34–0.5), folds and streaks are left out, and a test checks every preset.
504
-
505
- ![gridTitle](https://raw.githubusercontent.com/arraypress/covers/main/docs/gridTitle.webp)
506
-
507
- ### `diagram`
508
- Layers `bg`, `mesh`, `diagram`, `grain` (+ `title`). The field, toned a little deeper (lightness 0.4–0.6, so white lines clear 3:1, WCAG 1.4.11; tested on every preset), with white line-art and a dotted grid that breaks around it: the engineering-blog look. Options `icon`, `nodes`, `layout`, `arrows` (see the [`diagram` layer](#diagram)).
509
-
510
- ```js
511
- await renderImage({ recipe: 'diagram', seed: post.id, layout: 'hub',
512
- nodes: [{ icon: 'lucide:globe' }, { icon: 'lucide:database' }, { icon: 'lucide:database' }, { icon: 'lucide:database' }] });
513
- await renderImage({ recipe: 'diagram', seed: post.id, arrows: 'loop',
514
- nodes: [{ icon: 'lucide:send' }, { icon: 'lucide:settings' }, { icon: 'lucide:cpu' }] });
515
- ```
516
-
517
- ![diagram](https://raw.githubusercontent.com/arraypress/covers/main/docs/diagram.webp)
518
-
519
- ### `cutout`
520
- Layers `bg`, `panel`, `shapes`, `icon` or `graph`, `grain` (+ `title`). A flat, matte field (the accent toned down: lightness 0.52–0.64, chroma 0.05–0.11), cut-paper shapes in off-white, and a hand-drawn icon or node graph. `panel: true` sets a hand-cut panel on the page colour instead of a full field. `shapes` (3; none on a panel), `graph`, `stroke: 'light' | 'dark'` (default: dark over paper, light on a bare field or panel; both clear 3:1 on the field). Muted palettes suit it: `{ accent: '#8b80c9' }` (lavender), `'#b5694f'` (clay), `'#8a9a7b'` (sage), `'#c4a052'` (ochre), `'#5f6f7f'` (slate).
521
-
522
- ![cutout](https://raw.githubusercontent.com/arraypress/covers/main/docs/cutout.webp)
523
-
524
- ### `iconTile`
525
- Layers `bg`, `glow`, `rings`, `icon`, `grain` (+ `title`). An icon on a raised tile over a soft tint of the accent, a glow and faint rings. With a title, the tile moves to the top left. `icon` is an Iconify name (a spark when none is given).
526
-
527
- ![iconTile](https://raw.githubusercontent.com/arraypress/covers/main/docs/iconTile.webp)
528
-
529
- ### `bigType`
530
- Layers `bg`, `wash`, `grain`, `label`, `title`. The headline is the cover: large, tight (−0.045em), shrinking to fit four lines; the label in the accent at the top.
531
-
532
- ![bigType](https://raw.githubusercontent.com/arraypress/covers/main/docs/bigType.webp)
533
-
534
- ### `stripes`
535
- Layers `bg`, `stripes`, `spot`, `icon`, `grain`, `title`. A tonal band of tapering stripes down one side (`side: 'left' | 'right'`), a disc riding its edge with an optional `icon`, and the title on the open side.
536
-
537
- ![stripes](https://raw.githubusercontent.com/arraypress/covers/main/docs/stripes.webp)
538
-
539
- ### `duotone`
540
- Layers `image`, `scrim`, `grain`, `title`. A photo (`image`: a path or bytes) mapped to the palette's shadow and highlight colours, with a scrim under the title.
541
-
542
- ![duotone](https://raw.githubusercontent.com/arraypress/covers/main/docs/duotone.webp)
543
-
544
- ### `mark`
545
- Layers `bg`, `icon`, `grain` (+ `title`). One icon, centred, on the accent, or on a gradient with `background: 'gradient'` (135°, the accent toward the palette's second colour, mixed in OKLCH so it stays vivid; `angle` and `colors` override). `icon` is an Iconify name or raw SVG (the spark by default); `size` is `'24vmin'`; grain is 0.03 (`grain: false` removes it). A title appears bottom left only when given.
546
-
547
- `iconColor` is `'auto'` (default), `'white'`, `'black'` or a colour. Auto prefers **white** whenever white clears **3:1** (WCAG 1.4.11, non-text contrast; 4.5:1 is the rule for text) against everything under the icon: the fill, or for a gradient every colour across the icon's square (sampled along the gradient, not just its midpoint). Otherwise it's near-black. So a mid-tone orange-to-pink gradient keeps a white mark, and a yellow one gets a dark one. The title is text, so it keeps the icon's colour only where that clears 4.5:1 across the whole background.
548
-
549
- ```js
550
- await renderImage({ recipe: 'mark', seed: 'podcast', icon: 'lucide:music', background: 'gradient' });
551
- ```
552
-
553
- ![mark](https://raw.githubusercontent.com/arraypress/covers/main/docs/mark.webp)
554
-
555
- ### `riso`
556
- Layers `bg`, `inkA`, `inkB` (+ `inkC`, `title`). Two flat inks (three with `third: true`) overprinted on paper: big simple forms, misregistration, speckle or `texture: 'halftone'`, uneven density. `inks`: colours or classic inks (`['riso:pink', 'riso:blue']`); by default the palette's, toned to ink. On a dark palette the paper is the page colour and the inks print with `screen`.
557
-
558
- ![riso](https://raw.githubusercontent.com/arraypress/covers/main/docs/riso.webp)
559
-
560
- ### `dither`
561
- Layers `dither` (+ `title`). A noise field, gradient, or lit sphere / circle / arch, 1-bit. Options `matrix`, `cell`, `source`, `shape`, `three` (two dithered bands). A title sets on a solid block in the page colour. Encodes lossless by default (exact cells, ~1 KB; see Performance).
562
-
563
- ![dither](https://raw.githubusercontent.com/arraypress/covers/main/docs/dither.webp)
564
-
565
- ### `contour`
566
- Layers `bg`, `contour`, `grain` (+ `title`). Isolines on a faintly tinted field, an index contour every fifth line, one line in the accent. `levels` (seeded 14–30 by default).
567
-
568
- ![contour](https://raw.githubusercontent.com/arraypress/covers/main/docs/contour.webp)
569
-
570
- ### `waveform`
571
- Layers `bg`, `wave`, `grain` (+ `title`). `peaks` or `audio`, `style`, `field: 'flat' | 'toned'`, `duration` (seconds or text, shown under the wave). Bars clear 3:1 on the field; the title (top left) 4.5:1.
572
-
573
- ```js
574
- await renderImage({ recipe: 'waveform', seed: episode.slug, audio: 'episodes/42.mp3', title: 'Episode 42', label: 'Podcast' });
575
- ```
576
-
577
- ![waveform](https://raw.githubusercontent.com/arraypress/covers/main/docs/waveform.webp)
578
-
579
- ### `bauhaus`
580
- Layers `bg`, `tiles` (+ `title` in a band below). `series` keeps a family's grid rhythm: `cover({ recipe: 'bauhaus', seed: post.id, series: 'autumn-2026' })`.
581
-
582
- ![bauhaus](https://raw.githubusercontent.com/arraypress/covers/main/docs/bauhaus.webp)
583
-
584
- ### `issue`
585
- Layers `bg`, `masthead`, `date`, `rule`, `no`, `number` (+ `title`). A huge issue `number` in the accent (held to 3:1), set from the left, sized by its digit count to fill the width and cropped at the bottom only (at least 70% of it stays on the canvas on every aspect, tested); `masthead` and `date` above a hairline; "No." always above the numeral (`prefix: false` drops it). On wide canvases a title sits top right.
586
-
587
- ![issue](https://raw.githubusercontent.com/arraypress/covers/main/docs/issue.webp)
588
-
589
- ### `photo`
590
- Layers `bg`, `mesh` or `photo`, `grain` (+ `title`). The palette of a photo (`image`) as full-bleed sweeps, the photo itself not shown; `show: 'blur'` shows it heavily blurred instead. Needs sharp. `extractPalette(image)` is exported on its own.
591
-
592
- ![photo](https://raw.githubusercontent.com/arraypress/covers/main/docs/photo.webp)
593
-
594
- ### `brand`
595
- A brand on every format, the default for [`kit`](#brand-kit): avatars (the mark, or an initial), headers and banners (mark and name in the safe zone), posts and stories, business cards (front: mark and name; back: `person`, `role`, `lines`), letterhead and compliment slips, tickets (with a Code 128 `ticket` number), passes and stickers. Options: `name`, `tagline`, `mark`, `photo`, `lines`, `person`, `role`, `ticket`, `compliments`.
596
-
597
- ### Flyers (examples)
598
- `examples/recipes/`: `bloomfall.json` (riso, two misregistered inks, halftoned cut-out), `grid-rest-rhythm.json` (Neo-Swiss grid, justified headline), `wheatpaste-torn.json` (torn sheet, knockout type, creases). `flyers/` holds ~45 more (aura, Neo-Swiss, acid, riso, kinetic, festival, varsity, ephemera, Bauhaus, 70s, lo-fi, zine), each with a release cover, and `release/` 16 cover-only styles. Each is drawn on a design grid in a `group`, so it renders at any `format`.
599
-
600
- ### Your own
601
-
602
- ```js
603
- const poster = (o, { palette, rng, u }) => [
604
- { type: 'solid' },
605
- { type: 'shapes', count: rng.int(3, 6), kind: 'circle' },
606
- { type: 'text', content: o.title, size: u * 0.1, anchor: 'top-left' },
607
- ];
608
- await renderSvg({ recipe: poster, seed: 'x', title: 'Hello' });
609
- ```
610
-
611
- ## Palettes
612
-
613
- | Input | Means |
614
- |---|---|
615
- | `'dusk'` | A preset: `paper`, `ink`, `dawn`, `dusk`, `ocean`, `ember`, `moss`, `carbon`, `mono`. |
616
- | `'#2563eb'` | One colour: an accent to derive a palette from. |
617
- | `['#0c0d12', '#3d5afe', '#ff6b3d']` | `[bg, ...colors]`. |
618
- | `{ bg, colors, text, accent, muted, surface }` | Any subset; the rest is derived. |
619
-
620
- Resolved, a palette has `mode`, `bg`, `colors`, `text` (7:1 on `bg` when derived), `accent`, `onAccent` (white, or near-black when the accent is light), `muted` and `surface`.
621
-
622
- From a theme's tokens:
623
-
624
- ```js
625
- import { derivePalette } from '@arraypress/covers';
626
-
627
- const palette = derivePalette({ accent: '#2563eb', bg: '#fdfdfd', text: '#1d1d1f' });
628
- // or just: derivePalette({ accent: 'rgb(209 254 23)', mode: 'dark' })
629
- ```
630
-
631
- The gradient colours are built in OKLCH around the accent's hue (the accent, two neighbours ±40° and a quiet complement), pastel on light backgrounds and deep on dark ones. Colours can be hex, `rgb()` or `hsl()`; alpha is ignored.
632
-
633
- ## JSON recipes
634
-
635
- A cover style can be data: a JSON document, in a `.json` file or in a theme's config. The built-ins are written this way (`recipes/*.json`; read them for worked examples). Point `$schema` at the bundled schema for autocomplete and checking in your editor.
636
-
637
- ```json
638
- {
639
- "$schema": "https://unpkg.com/@arraypress/covers/schema/recipe.schema.json",
640
- "name": "house",
641
- "params": { "background": "solid", "angle": 135 },
642
- "vars": {
643
- "ink": { "readable": "@accent" },
644
- "lean": { "range": [-8, 8] }
645
- },
646
- "layers": [
647
- { "id": "bg", "type": "solid", "color": "@accent", "when": { "background": "solid" } },
648
- { "id": "bg", "type": "linear", "when": { "background": "gradient" },
649
- "angle": { "add": ["{angle}", "$lean"] },
650
- "colors": ["@accent", { "mix": ["@accent", "@colors.1", 0.5, "oklch"] }] },
651
- { "id": "grid", "type": "pattern", "kind": "grid", "color": "$ink", "fade": "edges",
652
- "opacity": { "if": { "mode": "dark" }, "then": 0.16, "else": 0.12 } },
653
- { "id": "grain", "type": "grain", "amount": 0.05 },
654
- { "id": "title", "type": "text", "when": "title", "content": "{title}", "label": "{label}",
655
- "color": "$ink", "size": { "mul": ["@u", 0.066] }, "maxLines": 3 }
656
- ]
657
- }
658
- ```
659
-
660
- ```js
661
- import { loadRecipe, renderImage } from '@arraypress/covers';
662
-
663
- const house = loadRecipe('./src/covers/house.json'); // or defineRecipe(json), or pass the object as `recipe`
664
- await renderImage({ recipe: house, seed: 'my-post', title: 'Hello', background: 'gradient' });
665
- ```
666
-
667
- A layer is any [layer](#layers) plus an optional `id` and `when`. Its option values can be literals or:
668
-
669
- | Form | Means |
670
- |---|---|
671
- | `"@bg"`, `"@text"`, `"@accent"`, `"@onAccent"`, `"@muted"`, `"@surface"`, `"@mode"`, `"@colors"`, `"@colors.N"` | The palette (`colors.N` wraps around). |
672
- | `"@width"`, `"@height"`, `"@u"` | The canvas in px (`u` = `sqrt(width × height)`, the unit the built-ins size type with). |
673
- | `"@safeX"`, `"@safeY"`, `"@safeWidth"`, `"@safeHeight"` | The safe box: a format's safe zone, a print piece's trim less its safe margin, else the canvas. |
674
- | `"@bleed"`, `"@safe"`, `"@trimWidth"`, `"@trimHeight"`, `"@mm"`, `"@dpi"` | Print set-up, px (undefined off print formats). |
675
- | `"{name}"`, `"{mark}"`, `"{photo}"`, `"{cutout}"`, `"{lines}"`, `"{lineup}"`, `"{dates}"`, `"{tracklist}"`, `"{venue}"`, `"{address}"`, `"{date}"`, `"{time}"`, `"{price}"`, `"{meta}"` | Brand and event data, readable without declaring them. `"{lineup.0}"`, `"{meta.venue}"` reach into lists and objects. |
676
- | `{ "join": [list, sep] }`, `{ "upper": s }`, `{ "lower": s }`, `{ "slice": [v, start, end] }` | Lists and strings. |
677
- | `{ "keyframes": [[t, value], …], "ease": "inOut", "loop": true }` | An animated value (see [Animation](#animation)). |
678
- | `"$name"`, `"$name.0"`, `"$name.x"` | A var (or an item or field of it). |
679
- | `"{layout.title.bottom}"`, `"{layout.title.bottom + 12}"` | Where an earlier text layer's block landed, filled in when drawn. |
680
- | `"{#col * 42 + 8}"` | Inside a `repeat`: an expression over the instance, filled in when drawn. |
681
- | `"{title}"`, `"{label}"`, `"{icon}"`, `"{image}"`, `"{param}"`, `"{opt.anything}"` | A cover option. A whole-string token keeps the value's type (a number stays a number, a label object stays an object); inside longer text it's interpolated (`"Read {title}"`). An unset option drops the key. |
682
- | `{ "mix": [a, b, t] }`, `{ "mix": [a, b, t, "oklch"] }` | Two colours mixed in OKLab, or in OKLCH (hue the short way, so far-apart hues stay vivid). |
683
- | `{ "readable": c }`, `{ "readable": [c, light, dark] }` | White (or `light`), or near-black (or `dark`), whichever reads better on `c`. |
684
- | `{ "readable": [backgrounds, ratio, prefer] }` | `prefer` (`"white"`, `"black"` or a colour) when it clears `ratio` on every background (one colour or a list), else the better of white and near-black. `[c, 3, "white"]` is the icon rule. |
685
- | `{ "tone": [color, { l: [min, max], c: [min, max] }] }` | The colour pulled into an OKLCH band, its hue kept. |
686
- | `{ "gradientUnder": [colors, angle, size] }` | The colours a centred square of `size` covers in a linear gradient (five samples, eased as drawn). Pair it with `readable`. |
687
- | `{ "pick": [...] }`, `{ "range": [min, max] }`, `{ "int": [min, max] }` | Seeded choices. |
688
- | `{ "add" / "sub" / "mul" / "div" / "min" / "max": [...] }` | Arithmetic. Lengths (`"54vw"`, `"12vmin"`) become px first. |
689
- | `{ "ramp": [from, to, steps, amount?] }` | `steps` colours from `from` toward `to` (`amount` of the way). |
690
- | `{ "taper": [ratio, steps] }` | `[1, ratio, ratio², …]`, e.g. stripe widths. |
691
- | `{ "length": value }` | Characters in a string (a number's digits) or items in an array. |
692
- | `{ "reverse": array }` | |
693
- | `{ "if": condition, "then": a, "else": b }` | A choice. Only the chosen side is evaluated (and draws from the stream). |
694
- | `"@@x"`, `"$$x"` | A literal `@x` / `$x`. |
695
-
696
- Conditions (a layer's `when`, an `if`): `"title"` (the option is set; an override object counts when it has `content`, `name`, `svg` or `src`), `{ "background": "gradient" }` (equals; an array means any of; `mode` is the palette's, `aspect` the canvas's: `'wide'`, `'square'` or `'tall'`, `format` the format's name, `page` the page being drawn), `{ "not": c }`, `{ "all": [...] }`, `{ "any": [...] }`.
697
-
698
- Layers nest (`group.layers`, `knockout`, `clip`, `mask`) and are validated the same way, `when` included. An `svg` layer's `markup` and `defs` are left alone except for `{{name}}` slots, which are filled from the options (escaped) and vars (`{{$name}}`); `{{@accent}}`, `{{id}}` and `{{seed}}` are filled when it's drawn.
699
-
700
- - **`params`** are the options a recipe reads, with defaults; a cover option of the same name overrides one. `title`, `label`, `icon` and `image` are always available. **`requires`** lists options that must be set (`["image"]`).
701
- - **`vars`** are resolved once, in order, before the layers; their draws use the cover's recipe stream. Draws inside a layer use that layer's own stream, so adding a layer never changes another's choices.
702
- - **`output`** (`{ "lossless": true }`) sets the encoding the style prefers in `renderImage`.
703
- - **`pages`** (`["front", "back"]`) makes the style two-sided (see [Two-sided pages](#two-sided-pages)).
704
- - **`fonts`** (`[{ "name": "Fraunces", "src": "fonts/Fraunces.woff2", "weight": [100, 900] }]`) declares the faces the style uses: a path beside the recipe file, or a URL. Callers don't register them.
705
- - **Overrides by id** work exactly as for the built-ins (`grain: false`, `mesh: { blur: 30 }`), and so do extra `layers`.
706
- - **Validation** runs when the recipe is defined, and stops at the first problem with the recipe's name, the JSON path and what to do: `Recipe "house": layers[2] (grid).blurr: "pattern" layers have no option "blurr" (kind, size, …)`, `unknown token "@bgg"`, `unknown param "subtitle" (declare it in "params", or use "{opt.subtitle}")`.
707
- - The schema is exported as `@arraypress/covers/recipe.schema.json`, and the built-ins as `@arraypress/covers/recipes/<name>.json` to copy and edit.
708
-
709
- All seventeen built-ins are JSON, with no JS fallback. The tests check that six of them render byte-identical SVG to JavaScript versions kept as a fixture, across palettes, aspects and options, object overrides included (the other ten were written as JSON from the start).
710
-
711
- ## Astro
712
-
713
- ```ts
714
- // src/pages/covers/[slug].webp.ts
715
- import { getCollection } from 'astro:content';
716
- import { coverEndpoint } from '@arraypress/covers/astro';
717
-
718
- export const { getStaticPaths, GET } = coverEndpoint({
719
- getEntries: () => getCollection('posts'),
720
- cover: (post) => ({ recipe: 'gridTitle', seed: post.id, title: post.data.title, aspect: '16:9' }),
721
- });
722
- ```
723
-
724
- `astro build` writes `dist/covers/<id>.webp` for every post; `astro dev` renders on request. Link to one with `coverUrl(post.id)` (→ `/covers/<id>.webp`).
725
-
726
- A theme can keep its cover style in its config as JSON and pass it straight through:
727
-
728
- ```ts
729
- // src/config/site.ts
730
- export const siteConfig = {
731
- covers: {
732
- palette: { accent: '#2563eb', bg: '#fdfdfd', text: '#1d1d1f' },
733
- recipe: {
734
- name: 'journal',
735
- params: { background: 'gradient' },
736
- layers: [
737
- { id: 'bg', type: 'linear', angle: 135, colors: ['@accent', { mix: ['@accent', '@colors.1', 0.5, 'oklch'] }] },
738
- { id: 'grain', type: 'grain', amount: 0.05 },
739
- { id: 'title', type: 'text', when: 'title', content: '{title}', color: '@onAccent', size: { mul: ['@u', 0.07] } },
740
- ],
741
- },
742
- },
743
- };
744
-
745
- // src/pages/covers/[slug].webp.ts
746
- import { getCollection } from 'astro:content';
747
- import { coverEndpoint } from '@arraypress/covers/astro';
748
- import { derivePalette } from '@arraypress/covers';
749
- import { siteConfig } from '../../config/site';
750
-
751
- const palette = derivePalette(siteConfig.covers.palette);
752
- export const { getStaticPaths, GET } = coverEndpoint({
753
- getEntries: () => getCollection('posts'),
754
- cover: (post) => ({ recipe: siteConfig.covers.recipe, seed: post.id, title: post.data.title, palette }),
755
- });
756
- ```
757
-
758
- Or keep it in a file: `recipe: loadRecipe('./src/covers/journal.json')`.
759
-
760
- | Option | Default | |
761
- |---|---|---|
762
- | `getEntries` | — | The entries. |
763
- | `cover` | — | An entry's cover options (may be async). |
764
- | `slug` | `entry.id ?? entry.slug` | |
765
- | `param` | `'slug'` | The route param, matching the file name. |
766
- | `format` | `'webp'` | Match the file's extension (`[slug].png.ts` → `'png'`). |
767
- | `quality` | per format | |
768
- | `cache` | `true` | See below. |
769
-
770
- Notes:
771
-
772
- - Entries with nested ids (`blog/2026/post`) need a rest route: `[...slug].webp.ts`.
773
- - In `output: 'server'`, add `export const prerender = true;` to keep the covers static. On-demand routes also work (the entry is looked up by slug).
774
- - `coverUrl(slug, { base, format })` encodes each path segment.
775
-
776
- ## Caching
777
-
778
- `renderImage(cover, { cache: true })` (the Astro helper's default) stores renders in `node_modules/.cache/covers`, keyed by a SHA-256 of the resolved cover: size, seed, palette, every layer option, font and image bytes (hashed by content), the format, quality and this package's version. An unchanged cover is read back instead of drawn; change anything and it renders again. Pass a directory instead of `true` to put the cache elsewhere (e.g. one your CI restores between builds).
779
-
780
- ## Performance
781
-
782
- Measured on an Apple M3 Max, Node 26, 1200×1200 WebP at the default quality (median of 5):
783
-
784
- | Recipe | `renderSvg` | `renderImage` | Size |
785
- |---|---|---|---|
786
- | aurora | <1 ms | 154 ms | 245 KB |
787
- | gridTitle | <1 ms | 139 ms | 196 KB |
788
- | iconTile | 2 ms | 103 ms | 37 KB |
789
- | bigType | 1 ms | 79 ms | 32 KB |
790
- | stripes | 1 ms | 84 ms | 69 KB |
791
- | duotone | 21 ms | 204 ms | 159 KB |
792
- | mark | 1 ms | 73 ms | 38 KB |
793
- | diagram | <1 ms | 113 ms | 110 KB |
794
- | cutout | 1 ms | 71 ms | 37 KB |
795
- | riso | 29 ms | 171 ms | 311 KB |
796
- | dither | 3 ms | 70 ms | 156 KB |
797
- | contour | 11 ms | 120 ms | 117 KB |
798
- | waveform | 1 ms | 68 ms | 24 KB |
799
- | bauhaus | <1 ms | 44 ms | 12 KB |
800
- | issue | 1 ms | 43 ms | 16 KB |
801
- | photo | <1 ms | 124 ms | 147 KB |
802
-
803
- (The gradient fields and riso are the heaviest files: saturated colour or speckle plus grain at WebP 90. See "Grain versus file size" below.)
804
-
805
- A 1200×630 `og` card is about 70 ms. The first render in a process adds ~80 ms (HarfBuzz's WebAssembly and the fonts load once). Cached covers cost a file read.
806
-
807
- How it rasterises, and why:
808
-
809
- - **Blur** is SVG `feGaussianBlur`, through sharp's librsvg. librsvg clips a filter's input to the canvas, so a blur near an edge pulls in transparency and the field fades there (a vignette). `renderImage` renders each `mesh` layer on its own canvas padded by 3σ, at the smallest scale its finest blur allows (blur this heavy has no detail to lose), then crops and upscales it: full-bleed, and cheaper than the unpadded render. Browsers draw the plain SVG's filters without that clip.
810
- - **Textures are image tiles.** The riso speckle and halftone are small PNG tiles in an SVG `<pattern>`, and dithers are one PNG pixel per cell scaled with `image-rendering: pixelated` (a minimal in-library PNG encoder, so no sharp needed for SVG). Two librsvg slow paths are avoided on purpose: a pattern of one shape per cell (~1.5 s a canvas) and a mask under a transform (~10×), which is why misregistration moves the shapes, not the texture.
811
- - **Grain** is not. librsvg's `feTurbulence` costs ~200 ms at that size, so `renderImage` splits the stack at each grain layer: the layers below are rasterised, seeded noise (a triangular distribution, closer to film than flat noise) is blended in JS with the W3C formulas (~10 ms; sharp's own overlay blend flattens partly transparent noise to almost nothing), and the layers above are composited on top. `renderSvg` keeps `feTurbulence`, which browsers draw quickly; the two grains are matched in strength.
812
- - **Filters at release size.** libvips hands librsvg large documents in a way that makes filter-heavy SVGs slow far past their pixel count (a 3000² displacement map: ~28 s in one piece, ~5 s as tiles), so `renderImage` draws an SVG with filters past ~2.4 MP as 1024 px tiles in parallel, each with a margin round it so blurs and displacements see across the seam. It also splits `feMorphology` into an x pass and a y pass (the same max, O(r) not O(r²)), and shrinks a filter's region to the box the layer paints when the chain can't paint outside its source (worked out from the primitives). Dithers are computed on pixels. Together these took the slowest release covers from minutes to seconds (see the CHANGELOG).
813
- - **Pixel effects** (spin, zoom, bokeh, distortions…) draw the layer once at its output pixels and work on typed arrays: polar resampling with running sums for spin and zoom (a long blur costs no more than a short one), an aperture convolved a row at a time from running totals for bokeh, and wide blurs on smaller copies. A full 3000² `renderImage` of a photo with any one effect takes 0.4–2.8 s (median 0.8 s; the photo alone 0.4 s); the slowest are `newsprint`, `motion` and `halftone`.
814
- - **Text** is shaped by HarfBuzz (`harfbuzzjs`, a 440 KB WebAssembly build) and written as path data. opentype.js was tried first and dropped: Inter's kerning lives in GPOS extension lookups that neither its 1.x nor 2.x reads, and 2.0 throws on Inter's contextual substitutions.
815
-
816
- **Grain versus file size.** Grain is the first thing a lossy encoder throws away. On a cover with grain, WebP quality 82 keeps about 40% of it, 90 about 60%, 94 about 78%, and the file grows each step (a dark aurora: 26 KB at 80, 60 KB at 85, 144 KB at 90; 19 KB at 90 without grain). **The default stays at WebP 90** (72 AVIF, 88 JPEG): the owner compared 80 and 90 on the gallery and 80 visibly loses the grain and starts to band the gradients. For a grid of small thumbnails, pass `quality: 80` or `grain: false` knowingly; for crisp grain, raise it or use PNG.
817
-
818
- **Lossless where it wins.** A recipe can declare the encoding it prefers (`"output": { "lossless": true }` in its JSON; `renderImage` options still win). `dither` does: it's flat 1-bit art, so lossless WebP is both exact and tiny, while lossy WebP smears the cells. Measured at 1200×1200:
819
-
820
- | Recipe | PNG | WebP lossless | WebP q90 | WebP q80 |
821
- |---|---|---|---|---|
822
- | dither (3 palettes) | 42–45 KB | **~1 KB**, exact | 153–240 KB, 3–5% of pixels wrong | 125–196 KB, 3.5–5% wrong |
823
- | contour (3 palettes) | 808–1074 KB | 511–661 KB | **145–183 KB**, visually exact | 101–135 KB |
824
-
825
- Contour carries grain, so it stays lossy like the rest. Pass `lossless: true` to `renderImage` for any cover.
826
-
827
- ## Tests
828
-
829
- `npm test` runs the suite, including:
830
-
831
- - **Byte identity with 1.0**: 694 covers (every built-in recipe across palettes, aspects and options, and every 1.0 layer on its own), hashed with the 1.0.0 code before any 1.1 change, must render the same SVG.
832
- - **Image filters**: numeric properties per effect (spin and zoom keep the centre pixel, threshold draws two colours, a lens turns a point into a disc of its radius, pixel sort keeps every pixel of a row, a turned motion blur is directional, masks leave the layer exact where they're 0), seeds, validation and the Worker fallback; `COVERS_SLOW=1` renders every effect at 3000².
833
- - **A browser smoke test** in headless Chrome over the DevTools protocol (`COVERS_CDP`, default `http://localhost:9334`; skipped without one): vector and text covers draw the same bytes as Node, the pixel layers render, `renderBlob` encodes PNG and WebP, and HarfBuzz loads its wasm from a URL.
834
-
835
- `npm run test:worker` runs covers inside workerd with `wrangler dev` (needs wrangler).
836
-
837
- ## Gallery
838
-
839
- ```bash
840
- npm run gallery
841
- ```
48
+ ## Links
842
49
 
843
- renders every recipe × 3 seeds × 2 palettes × 2 aspects into `examples/out/`, with an `index.html` to look through and a contact sheet per recipe. `node examples/docs-images.mjs` rebuilds the images in this README.
50
+ - [Docs](https://github.com/arraypress/covers/tree/main/docs)
51
+ - [Changelog](https://github.com/arraypress/covers/blob/main/CHANGELOG.md)
52
+ - [Licence](https://github.com/arraypress/covers/blob/main/LICENSE)
844
53
 
845
54
  ## Licence
846
55
 
847
- MIT © ArrayPress Limited. Inter is © The Inter Project Authors, SIL Open Font License 1.1 (`fonts/OFL.txt`). The example photo is from Unsplash (`examples/CREDITS.md`) and isn't published with the package.
56
+ MIT © ArrayPress Limited; author David Sherlock. Inter is © The Inter Project Authors, SIL Open Font License 1.1 (`fonts/OFL.txt`). The example photo is from Unsplash (`examples/CREDITS.md`) and isn't published with the package.