@arraypress/covers 1.0.0 → 1.3.1

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 (246) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +18 -524
  3. package/package.json +34 -5
  4. package/recipes/brand.json +1143 -0
  5. package/recipes/flyers/afterglow-motion.json +540 -0
  6. package/recipes/flyers/afterglow.json +70 -0
  7. package/recipes/flyers/arcfield-mirror-motion.json +397 -0
  8. package/recipes/flyers/arcfield-mirror.json +364 -0
  9. package/recipes/flyers/bauhaus-oktav.json +319 -0
  10. package/recipes/flyers/bauhaus-ritmo.json +735 -0
  11. package/recipes/flyers/bauhaus-volta.json +394 -0
  12. package/recipes/flyers/bloomfall.json +99 -0
  13. package/recipes/flyers/drag-solenne-motion.json +444 -0
  14. package/recipes/flyers/drag-solenne.json +426 -0
  15. package/recipes/flyers/fennick-patches.json +874 -0
  16. package/recipes/flyers/ferro-pass.json +398 -0
  17. package/recipes/flyers/ferrova.json +812 -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/lumio-stack-motion.json +648 -0
  34. package/recipes/flyers/lumio-stack.json +438 -0
  35. package/recipes/flyers/magazine-cover.json +168 -0
  36. package/recipes/flyers/magazine-extended.json +269 -0
  37. package/recipes/flyers/magazine-knockout.json +40 -0
  38. package/recipes/flyers/northfold-varsity.json +618 -0
  39. package/recipes/flyers/open-studio.json +69 -0
  40. package/recipes/flyers/orrin-merch.json +715 -0
  41. package/recipes/flyers/oxyde.json +113 -0
  42. package/recipes/flyers/quiet-fields.json +610 -0
  43. package/recipes/flyers/scrapbook-halcyon.json +948 -0
  44. package/recipes/flyers/solenne-ticket.json +730 -0
  45. package/recipes/flyers/solvane-motion.json +576 -0
  46. package/recipes/flyers/solvane.json +98 -0
  47. package/recipes/flyers/subduct.json +99 -0
  48. package/recipes/flyers/sunvale-lineup.json +584 -0
  49. package/recipes/flyers/tidewell.json +91 -0
  50. package/recipes/flyers/tour-kairo.json +535 -0
  51. package/recipes/flyers/velora-stickers.json +885 -0
  52. package/recipes/flyers/wheatpaste-black.json +313 -0
  53. package/recipes/flyers/wheatpaste-mark.json +198 -0
  54. package/recipes/flyers/wheatpaste-torn.json +114 -0
  55. package/recipes/flyers/wheatpaste-wall.json +984 -0
  56. package/recipes/flyers/wheatpaste-words.json +233 -0
  57. package/recipes/release/aura.json +137 -0
  58. package/recipes/release/chrome.json +433 -0
  59. package/recipes/release/collage.json +372 -0
  60. package/recipes/release/duotone.json +113 -0
  61. package/recipes/release/film-still.json +102 -0
  62. package/recipes/release/headline.json +70 -0
  63. package/recipes/release/instant.json +170 -0
  64. package/recipes/release/label.json +364 -0
  65. package/recipes/release/liquid-glass.json +301 -0
  66. package/recipes/release/odd-tile-dark.json +688 -0
  67. package/recipes/release/odd-tile-dense.json +1056 -0
  68. package/recipes/release/odd-tile-shift.json +541 -0
  69. package/recipes/release/odd-tile-split.json +571 -0
  70. package/recipes/release/odd-tile.json +633 -0
  71. package/recipes/release/off-edge.json +96 -0
  72. package/recipes/release/plinth.json +794 -0
  73. package/recipes/release/riso.json +108 -0
  74. package/recipes/release/series/fluid-label.json +561 -0
  75. package/recipes/release/series/fluid-pixel.json +334 -0
  76. package/recipes/release/series/hairline.json +305 -0
  77. package/recipes/release/series/halo.json +282 -0
  78. package/recipes/release/series/mark-crop.json +135 -0
  79. package/recipes/release/series/mark-window.json +150 -0
  80. package/recipes/release/series/photo-label.json +475 -0
  81. package/recipes/release/series/smear-field.json +345 -0
  82. package/recipes/release/series/smear.json +202 -0
  83. package/recipes/release/series/spin.json +172 -0
  84. package/recipes/release/series/split-ghost.json +230 -0
  85. package/recipes/release/series/zoom.json +168 -0
  86. package/recipes/release/small-print.json +114 -0
  87. package/recipes/release/stack.json +156 -0
  88. package/recipes/release/swiss.json +117 -0
  89. package/schema/recipe.schema.json +7624 -827
  90. package/src/animate/index.d.ts +9 -0
  91. package/src/animate/index.js +11 -0
  92. package/src/animate/keyframes.d.ts +21 -0
  93. package/src/animate/keyframes.js +119 -0
  94. package/src/animate/marquee.d.ts +30 -0
  95. package/src/animate/marquee.js +93 -0
  96. package/src/animate/morph.d.ts +8 -0
  97. package/src/animate/morph.js +99 -0
  98. package/src/animate/motion.d.ts +59 -0
  99. package/src/animate/motion.js +63 -0
  100. package/src/animate/sheet.d.ts +19 -0
  101. package/src/animate/sheet.js +61 -0
  102. package/src/animate/timing.d.ts +28 -0
  103. package/src/animate/timing.js +146 -0
  104. package/src/animate/tracks.d.ts +18 -0
  105. package/src/animate/tracks.js +139 -0
  106. package/src/builtins.js +4 -0
  107. package/src/diagram.js +4 -3
  108. package/src/extract.js +6 -5
  109. package/src/fonts.d.ts +55 -0
  110. package/src/fonts.js +245 -53
  111. package/src/formats/cut.d.ts +10 -0
  112. package/src/formats/cut.js +77 -0
  113. package/src/formats/index.d.ts +6 -0
  114. package/src/formats/index.js +9 -0
  115. package/src/formats/presets.d.ts +41 -0
  116. package/src/formats/presets.js +89 -0
  117. package/src/formats/print.d.ts +25 -0
  118. package/src/formats/print.js +117 -0
  119. package/src/formats/zones.d.ts +10 -0
  120. package/src/formats/zones.js +64 -0
  121. package/src/geometry.js +5 -35
  122. package/src/grid.js +1 -1
  123. package/src/handmade.js +9 -7
  124. package/src/icons.js +42 -9
  125. package/src/index.d.ts +232 -55
  126. package/src/index.js +14 -6
  127. package/src/inter-data.js +27 -0
  128. package/src/json-recipe.js +201 -27
  129. package/src/kit/index.d.ts +20 -0
  130. package/src/kit/index.js +58 -0
  131. package/src/layer-options.js +43 -4
  132. package/src/layers/barcode.d.ts +19 -0
  133. package/src/layers/barcode.js +63 -0
  134. package/src/layers/code128.d.ts +4 -0
  135. package/src/layers/code128.js +28 -0
  136. package/src/layers/expr.d.ts +6 -0
  137. package/src/layers/expr.js +121 -0
  138. package/src/layers/geometry.d.ts +6 -0
  139. package/src/layers/geometry.js +238 -0
  140. package/src/layers/index.d.ts +18 -0
  141. package/src/layers/index.js +15 -0
  142. package/src/layers/perforation.d.ts +14 -0
  143. package/src/layers/perforation.js +47 -0
  144. package/src/layers/repeat.d.ts +29 -0
  145. package/src/layers/repeat.js +90 -0
  146. package/src/layers/shape.d.ts +46 -0
  147. package/src/layers/shape.js +115 -0
  148. package/src/layers/soft.d.ts +5 -0
  149. package/src/layers/soft.js +43 -0
  150. package/src/layers/svg.d.ts +23 -0
  151. package/src/layers/svg.js +108 -0
  152. package/src/layers/table.d.ts +25 -0
  153. package/src/layers/table.js +80 -0
  154. package/src/layers/tear.d.ts +21 -0
  155. package/src/layers/tear.js +74 -0
  156. package/src/layers/texture.d.ts +17 -0
  157. package/src/layers/texture.js +106 -0
  158. package/src/layers/util.d.ts +8 -0
  159. package/src/layers/util.js +61 -0
  160. package/src/layers.js +129 -61
  161. package/src/lint/image-size.d.ts +4 -0
  162. package/src/lint/image-size.js +34 -0
  163. package/src/lint/index.d.ts +16 -0
  164. package/src/lint/index.js +127 -0
  165. package/src/lint/rules.d.ts +5 -0
  166. package/src/lint/rules.js +19 -0
  167. package/src/node.d.ts +2 -0
  168. package/src/node.js +20 -0
  169. package/src/paint/index.d.ts +49 -0
  170. package/src/paint/index.js +66 -0
  171. package/src/paint/presets.d.ts +10 -0
  172. package/src/paint/presets.js +47 -0
  173. package/src/paint/tile.d.ts +7 -0
  174. package/src/paint/tile.js +55 -0
  175. package/src/palette.js +1 -1
  176. package/src/platform/files.d.ts +6 -0
  177. package/src/platform/files.js +41 -0
  178. package/src/platform/harfbuzz.d.ts +9 -0
  179. package/src/platform/harfbuzz.js +80 -0
  180. package/src/platform/index.d.ts +8 -0
  181. package/src/platform/index.js +15 -0
  182. package/src/platform/load.d.ts +6 -0
  183. package/src/platform/load.js +71 -0
  184. package/src/platform/no-harfbuzz.d.ts +2 -0
  185. package/src/platform/no-harfbuzz.js +11 -0
  186. package/src/platform/settings.d.ts +28 -0
  187. package/src/platform/settings.js +51 -0
  188. package/src/print.js +16 -13
  189. package/src/recipes.js +5 -4
  190. package/src/render/backdrop.d.ts +6 -0
  191. package/src/render/backdrop.js +55 -0
  192. package/src/render/context.d.ts +82 -0
  193. package/src/render/grain.d.ts +7 -0
  194. package/src/render/grain.js +113 -0
  195. package/src/render/group.d.ts +23 -0
  196. package/src/render/group.js +67 -0
  197. package/src/render/key.d.ts +5 -0
  198. package/src/render/key.js +90 -0
  199. package/src/render/report.d.ts +5 -0
  200. package/src/render/report.js +19 -0
  201. package/src/render/transform.d.ts +36 -0
  202. package/src/render/transform.js +139 -0
  203. package/src/render/use.d.ts +27 -0
  204. package/src/render/use.js +73 -0
  205. package/src/render/wrap.d.ts +14 -0
  206. package/src/render/wrap.js +144 -0
  207. package/src/render.js +343 -215
  208. package/src/text.d.ts +29 -0
  209. package/src/text.js +23 -16
  210. package/src/type/auto-color.d.ts +9 -0
  211. package/src/type/auto-color.js +44 -0
  212. package/src/type/fallback.d.ts +11 -0
  213. package/src/type/fallback.js +92 -0
  214. package/src/type/glyphs.d.ts +24 -0
  215. package/src/type/glyphs.js +87 -0
  216. package/src/type/index.d.ts +5 -0
  217. package/src/type/index.js +10 -0
  218. package/src/type/kern.d.ts +4 -0
  219. package/src/type/kern.js +144 -0
  220. package/src/type/layout-ref.d.ts +10 -0
  221. package/src/type/layout-ref.js +74 -0
  222. package/src/type/line.d.ts +11 -0
  223. package/src/type/line.js +105 -0
  224. package/src/type/live.d.ts +6 -0
  225. package/src/type/live.js +39 -0
  226. package/src/type/name.d.ts +4 -0
  227. package/src/type/name.js +32 -0
  228. package/src/type/path.d.ts +18 -0
  229. package/src/type/path.js +89 -0
  230. package/src/type/pixel.d.ts +7 -0
  231. package/src/type/pixel.js +85 -0
  232. package/src/type/sfnt.d.ts +20 -0
  233. package/src/type/sfnt.js +258 -0
  234. package/src/type/spans.d.ts +33 -0
  235. package/src/type/spans.js +138 -0
  236. package/src/type/symbols.d.ts +7 -0
  237. package/src/type/symbols.js +89 -0
  238. package/src/type/typeset.d.ts +10 -0
  239. package/src/type/typeset.js +363 -0
  240. package/src/type/woff2.d.ts +6 -0
  241. package/src/type/woff2.js +301 -0
  242. package/src/version.js +4 -0
  243. package/src/color.js +0 -246
  244. package/src/png.js +0 -77
  245. package/src/rng.js +0 -95
  246. package/src/svg.js +0 -69
package/README.md CHANGED
@@ -1,10 +1,6 @@
1
1
  # @arraypress/covers
2
2
 
3
- Build-time cover images for posts, products and pages: soft mesh gradients, film grain, fine grids, stripes, shapes, icons and type, drawn from one **seed**. The same slug always gives the same image, and two posts never look alike.
4
-
5
- Sixteen recipes cover the editorial looks (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 network, no telemetry, no DOM. It runs in Node at build time.
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,528 +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
- | `renderImage(cover, { format, quality, lossless, effort, cache })` | `Promise<Buffer>`: `'webp'` (default), `'png'`, `'avif'` or `'jpeg'`; `lossless` for WebP/AVIF (default: the recipe's preference). |
62
- | `coverKey(cover, options)` | The content-hash cache key (see [Caching](#caching)). |
63
- | `recipes` | The built-in recipes, by name. |
64
- | `defineRecipe(json)`, `loadRecipe(path)` | A recipe from a JSON document or file (see [JSON recipes](#json-recipes)). |
65
- | `palettes`, `resolvePalette(input)`, `derivePalette(tokens)` | Palettes (see [Palettes](#palettes)). |
66
- | `registerIcons(iconifySet)` | Use an icon set without installing its package. |
67
- | `extractPalette(image, { count })` | A photo's dominant colours (k-means in OKLab; needs sharp). |
68
- | `RISO_INKS` | Classic risograph inks by name. |
69
- | `toHex`, `toOklch`, `fromOklch`, `mixOklab`, `mixOklch`, `onBackground`, `gradientAt` | Colour helpers (`onBackground(bgs, 3, 'white')` is the icon-contrast rule). |
70
- | `createRng(seed)`, `hashString(str)` | The seeded randomness, for custom recipes. |
71
- | `resolveSize`, `ASPECTS`, `MIME`, `DEFAULT_QUALITY`, `VERSION` | |
72
-
73
- ### Cover options
74
-
75
- | Option | Default | |
76
- |---|---|---|
77
- | `seed` | the title | Drives every random choice. Use the slug. |
78
- | `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. |
79
- | `palette` | `'paper'` | A preset, one colour, `[bg, ...colors]`, an object, or `derivePalette()`. |
80
- | `recipe` | — | A recipe name, your own function, or a JSON recipe document. |
81
- | `layers` | mesh + grain | Without a recipe: the stack. With one: extra layers on top. |
82
- | `fonts` | Inter | `[{ name, data, weight, style }]`, `.ttf`/`.otf`/`.woff` (not woff2). Inter 400/500/600/700 is always available. |
83
- | `alt` | the title | The SVG's `<title>`. |
84
- | `radius` | — | Rounded corners, transparent outside. |
85
- | any layer id | — | Customise a recipe's layer (see [Recipes](#recipes)). |
86
-
87
- ### Lengths
88
-
89
- 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 (`x`, `y`, `region`) are fractions of the canvas, 0–1.
90
-
91
- ## Layers
92
-
93
- 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`).
94
-
95
- 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.
96
-
97
- ### `solid`
98
- `color` (default `bg`).
99
-
100
- ### `linear`
101
- `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).
102
-
103
- ### `radial`
104
- `colors` (centre → edge; default `[colors[0], bg]`), `x`, `y` (default seeded), `radius` (default `'80vmax'`), `ease`.
105
-
106
- ### `mesh`
107
- Full-bleed fields of blurred colour: the mesh-gradient look.
108
-
109
- | Option | Default | |
110
- |---|---|---|
111
- | `colors` | the palette's | |
112
- | `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. |
113
- | `hues` | 3 | How many distinct colours to use. A few hues read as a colour story; every hue at once reads as a rainbow. |
114
- | `count` | 4 | Fields. |
115
- | `arrangement` | `'sweep'` | Long fields laid along one direction, their centres free to sit beyond the frame. `'ring'` spreads rounder blobs around the centre. |
116
- | `angle` | seeded | The sweep's direction, degrees. |
117
- | `streak` | 1.5 | How much more a sweep blurs along its direction than across it (broad washes rather than spots). |
118
- | `blur` | `'11vmax'` | Gaussian std deviation. |
119
- | `spread` | 1 | How far fields wander, 0–1. |
120
- | `size` | `[0.42, 0.72]` | Radius range, fractions of the longer side. |
121
- | `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. |
122
- | `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). |
123
- | `streaks` | 0 | Thin light motion streaks along the sweep. |
124
- | `from` | — | A photo (path or bytes): take the colours from it (needs sharp; see `extractPalette`). |
125
- | `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`. |
126
- | `blobs` | — | `[{ x, y, r, squash, rotate, color, opacity }]` to place them yourself. |
127
-
128
- ### `stripes`
129
- `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`.
130
-
131
- ### `image`
132
- `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`.
133
-
134
- With sharp installed the image is cropped and resized to its box before embedding. Without it, PNG, JPEG and SVG are embedded as they are.
135
-
136
- ### `grain`
137
- `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'`).
138
-
139
- ### `pattern`
140
- `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`.
141
-
142
- 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.
143
-
144
- ### `shapes`
145
- `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).
146
-
147
- ### `icon`
148
- 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`), `strokeWidth`, `anchor`, `position`, `offset`, `margin`, and `tile` (`true` or `{ size, radius, color, shadow: 'soft' | 'none', border }`).
149
-
150
- Named icons load from `@iconify-json/<prefix>` (resolved from your project), or from a set you pass to `registerIcons()`.
151
-
152
- ### `text`
153
- Set as outlines, so the SVG needs no fonts.
154
-
155
- | Option | Default | |
156
- |---|---|---|
157
- | `content` | — | `\n` breaks a line. |
158
- | `font`, `weight`, `style` | `'Inter'`, 600 | The closest weight in the family wins. |
159
- | `size` | 6% of `sqrt(w × h)` | |
160
- | `minSize` | — | Shrink toward this before truncating. |
161
- | `lineHeight` | 1.12 | |
162
- | `letterSpacing` | -0.02 | Em. Tighten big type further (`-0.045`). |
163
- | `color` | `text` | |
164
- | `anchor` | `'bottom-left'` | Nine anchors, inside the safe `margin` (7% of `sqrt(w × h)`). |
165
- | `align` | from the anchor | |
166
- | `position`, `offset` | — | |
167
- | `maxWidth` | width less margins | |
168
- | `maxLines` | 3 | Past it, cut at a word with `ellipsis` (`'…'`). |
169
- | `balance` | `true` | Even line lengths (CSS `text-wrap: balance`). |
170
- | `transform` | — | `'uppercase'` / `'lowercase'`. |
171
- | `label` | — | A small line above: a string, or `{ content, size, weight, color, letterSpacing, transform, gap }` (uppercase, tracked, `muted` by default). |
172
- | `background` | — | A pill or box behind: a colour, or `{ color, radius, padding, opacity }`. |
173
-
174
- 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).
175
-
176
- ### `diagram`
177
- White line-art: one icon, or nodes (icons in outlined tiles) joined by arrows.
178
-
179
- | Option | Default | |
180
- |---|---|---|
181
- | `icon` | — | One icon, drawn large (an Iconify name or raw SVG). |
182
- | `nodes` | — | `[{ icon, shape: 'square' \| 'circle' \| 'none', size, brackets }]`. |
183
- | `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). |
184
- | `arrows` | `true` | `false`, `'loop'` (row: an elbow back from the last to the first), `'both'` (triangle: there and back), `'cycle'` (square). |
185
- | `size`, `gap` | per layout | Node size and spacing. |
186
- | `stroke` | `'0.3vmin'` | One line weight for tiles, arrows and icons (Lucide strokes are rescaled to match). |
187
- | `color` | white | |
188
- | `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. |
189
- | `iconScale`, `radius`, `position` | 0.44, — , centre | |
190
-
191
- ### `paper`
192
- 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).
193
-
194
- ### `scribble`
195
- 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).
196
-
197
- ### `ink`
198
- 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.
199
-
200
- ### `dither`
201
- 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.
34
+ ## What's in it
202
35
 
203
- ### `contour`
204
- 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`.
205
-
206
- ### `waveform`
207
- 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.
208
-
209
- ### `tiles`
210
- 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`.
211
-
212
- ### `rule`
213
- A hairline: `y` (fraction), or `from` / `to` points; `inset`, `color`, `stroke`.
214
-
215
- ### `frame`
216
- `inset` (`'3.5vmin'`), `radius` (`'2vmin'`), `stroke` (`'0.15vmin'`), `color` (`text`, at opacity 0.22), `fill` (a colour, to make a card).
217
-
218
- ## Recipes
219
-
220
- A recipe returns a stack of layers with ids. Customise any of them by id:
221
-
222
- - a **string** is the layer's content: `title: 'Hello'`, `icon: 'lucide:feather'`
223
- - an **object** is merged into the layer: `mesh: { blur: '16vmax' }`, `title: { content: 'Hello', size: 96 }`
224
- - **`false`** removes it: `grain: false`
225
-
226
- ```js
227
- await renderImage({
228
- recipe: 'gridTitle',
229
- seed: post.slug,
230
- title: post.title,
231
- mesh: { hues: 2, blur: '14vmax' },
232
- grid: { kind: 'dots' },
233
- layers: [{ type: 'frame' }], // painted on top
234
- });
235
- ```
236
-
237
- Every recipe takes an optional `title` and `label`.
238
-
239
- ### `aurora`
240
- 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.
241
-
242
- ![aurora](https://raw.githubusercontent.com/arraypress/covers/main/docs/aurora.webp)
243
-
244
- ### `gridTitle`
245
- Layers `bg`, `mesh`, `grid`, `grain` (+ `title`). The same field under a dotted grid of four cells each way.
246
-
247
- 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.
248
-
249
- ![gridTitle](https://raw.githubusercontent.com/arraypress/covers/main/docs/gridTitle.webp)
250
-
251
- ### `diagram`
252
- 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)).
253
-
254
- ```js
255
- await renderImage({ recipe: 'diagram', seed: post.id, layout: 'hub',
256
- nodes: [{ icon: 'lucide:globe' }, { icon: 'lucide:database' }, { icon: 'lucide:database' }, { icon: 'lucide:database' }] });
257
- await renderImage({ recipe: 'diagram', seed: post.id, arrows: 'loop',
258
- nodes: [{ icon: 'lucide:send' }, { icon: 'lucide:settings' }, { icon: 'lucide:cpu' }] });
259
- ```
260
-
261
- ![diagram](https://raw.githubusercontent.com/arraypress/covers/main/docs/diagram.webp)
262
-
263
- ### `cutout`
264
- 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).
265
-
266
- ![cutout](https://raw.githubusercontent.com/arraypress/covers/main/docs/cutout.webp)
267
-
268
- ### `iconTile`
269
- 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).
270
-
271
- ![iconTile](https://raw.githubusercontent.com/arraypress/covers/main/docs/iconTile.webp)
272
-
273
- ### `bigType`
274
- 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.
275
-
276
- ![bigType](https://raw.githubusercontent.com/arraypress/covers/main/docs/bigType.webp)
277
-
278
- ### `stripes`
279
- 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.
280
-
281
- ![stripes](https://raw.githubusercontent.com/arraypress/covers/main/docs/stripes.webp)
282
-
283
- ### `duotone`
284
- 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.
285
-
286
- ![duotone](https://raw.githubusercontent.com/arraypress/covers/main/docs/duotone.webp)
287
-
288
- ### `mark`
289
- 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.
290
-
291
- `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.
292
-
293
- ```js
294
- await renderImage({ recipe: 'mark', seed: 'podcast', icon: 'lucide:music', background: 'gradient' });
295
- ```
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, 84 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)
296
47
 
297
- ![mark](https://raw.githubusercontent.com/arraypress/covers/main/docs/mark.webp)
298
-
299
- ### `riso`
300
- 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`.
301
-
302
- ![riso](https://raw.githubusercontent.com/arraypress/covers/main/docs/riso.webp)
303
-
304
- ### `dither`
305
- 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).
306
-
307
- ![dither](https://raw.githubusercontent.com/arraypress/covers/main/docs/dither.webp)
308
-
309
- ### `contour`
310
- 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).
311
-
312
- ![contour](https://raw.githubusercontent.com/arraypress/covers/main/docs/contour.webp)
313
-
314
- ### `waveform`
315
- 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.
316
-
317
- ```js
318
- await renderImage({ recipe: 'waveform', seed: episode.slug, audio: 'episodes/42.mp3', title: 'Episode 42', label: 'Podcast' });
319
- ```
320
-
321
- ![waveform](https://raw.githubusercontent.com/arraypress/covers/main/docs/waveform.webp)
322
-
323
- ### `bauhaus`
324
- Layers `bg`, `tiles` (+ `title` in a band below). `series` keeps a family's grid rhythm: `cover({ recipe: 'bauhaus', seed: post.id, series: 'autumn-2026' })`.
325
-
326
- ![bauhaus](https://raw.githubusercontent.com/arraypress/covers/main/docs/bauhaus.webp)
327
-
328
- ### `issue`
329
- 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.
330
-
331
- ![issue](https://raw.githubusercontent.com/arraypress/covers/main/docs/issue.webp)
332
-
333
- ### `photo`
334
- 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.
335
-
336
- ![photo](https://raw.githubusercontent.com/arraypress/covers/main/docs/photo.webp)
337
-
338
- ### Your own
339
-
340
- ```js
341
- const poster = (o, { palette, rng, u }) => [
342
- { type: 'solid' },
343
- { type: 'shapes', count: rng.int(3, 6), kind: 'circle' },
344
- { type: 'text', content: o.title, size: u * 0.1, anchor: 'top-left' },
345
- ];
346
- await renderSvg({ recipe: poster, seed: 'x', title: 'Hello' });
347
- ```
348
-
349
- ## Palettes
350
-
351
- | Input | Means |
352
- |---|---|
353
- | `'dusk'` | A preset: `paper`, `ink`, `dawn`, `dusk`, `ocean`, `ember`, `moss`, `carbon`, `mono`. |
354
- | `'#2563eb'` | One colour: an accent to derive a palette from. |
355
- | `['#0c0d12', '#3d5afe', '#ff6b3d']` | `[bg, ...colors]`. |
356
- | `{ bg, colors, text, accent, muted, surface }` | Any subset; the rest is derived. |
357
-
358
- 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`.
359
-
360
- From a theme's tokens:
361
-
362
- ```js
363
- import { derivePalette } from '@arraypress/covers';
364
-
365
- const palette = derivePalette({ accent: '#2563eb', bg: '#fdfdfd', text: '#1d1d1f' });
366
- // or just: derivePalette({ accent: 'rgb(209 254 23)', mode: 'dark' })
367
- ```
368
-
369
- 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.
370
-
371
- ## JSON recipes
372
-
373
- 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.
374
-
375
- ```json
376
- {
377
- "$schema": "https://unpkg.com/@arraypress/covers/schema/recipe.schema.json",
378
- "name": "house",
379
- "params": { "background": "solid", "angle": 135 },
380
- "vars": {
381
- "ink": { "readable": "@accent" },
382
- "lean": { "range": [-8, 8] }
383
- },
384
- "layers": [
385
- { "id": "bg", "type": "solid", "color": "@accent", "when": { "background": "solid" } },
386
- { "id": "bg", "type": "linear", "when": { "background": "gradient" },
387
- "angle": { "add": ["{angle}", "$lean"] },
388
- "colors": ["@accent", { "mix": ["@accent", "@colors.1", 0.5, "oklch"] }] },
389
- { "id": "grid", "type": "pattern", "kind": "grid", "color": "$ink", "fade": "edges",
390
- "opacity": { "if": { "mode": "dark" }, "then": 0.16, "else": 0.12 } },
391
- { "id": "grain", "type": "grain", "amount": 0.05 },
392
- { "id": "title", "type": "text", "when": "title", "content": "{title}", "label": "{label}",
393
- "color": "$ink", "size": { "mul": ["@u", 0.066] }, "maxLines": 3 }
394
- ]
395
- }
396
- ```
397
-
398
- ```js
399
- import { loadRecipe, renderImage } from '@arraypress/covers';
400
-
401
- const house = loadRecipe('./src/covers/house.json'); // or defineRecipe(json), or pass the object as `recipe`
402
- await renderImage({ recipe: house, seed: 'my-post', title: 'Hello', background: 'gradient' });
403
- ```
404
-
405
- A layer is any [layer](#layers) plus an optional `id` and `when`. Its option values can be literals or:
406
-
407
- | Form | Means |
408
- |---|---|
409
- | `"@bg"`, `"@text"`, `"@accent"`, `"@onAccent"`, `"@muted"`, `"@surface"`, `"@mode"`, `"@colors"`, `"@colors.N"` | The palette (`colors.N` wraps around). |
410
- | `"@width"`, `"@height"`, `"@u"` | The canvas in px (`u` = `sqrt(width × height)`, the unit the built-ins size type with). |
411
- | `"$name"`, `"$name.0"`, `"$name.x"` | A var (or an item or field of it). |
412
- | `"{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. |
413
- | `{ "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). |
414
- | `{ "readable": c }`, `{ "readable": [c, light, dark] }` | White (or `light`), or near-black (or `dark`), whichever reads better on `c`. |
415
- | `{ "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. |
416
- | `{ "tone": [color, { l: [min, max], c: [min, max] }] }` | The colour pulled into an OKLCH band, its hue kept. |
417
- | `{ "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`. |
418
- | `{ "pick": [...] }`, `{ "range": [min, max] }`, `{ "int": [min, max] }` | Seeded choices. |
419
- | `{ "add" / "sub" / "mul" / "div" / "min" / "max": [...] }` | Arithmetic. Lengths (`"54vw"`, `"12vmin"`) become px first. |
420
- | `{ "ramp": [from, to, steps, amount?] }` | `steps` colours from `from` toward `to` (`amount` of the way). |
421
- | `{ "taper": [ratio, steps] }` | `[1, ratio, ratio², …]`, e.g. stripe widths. |
422
- | `{ "length": value }` | Characters in a string (a number's digits) or items in an array. |
423
- | `{ "reverse": array }` | |
424
- | `{ "if": condition, "then": a, "else": b }` | A choice. Only the chosen side is evaluated (and draws from the stream). |
425
- | `"@@x"`, `"$$x"` | A literal `@x` / `$x`. |
426
-
427
- 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'`), `{ "not": c }`, `{ "all": [...] }`, `{ "any": [...] }`.
428
-
429
- - **`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"]`).
430
- - **`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.
431
- - **`output`** (`{ "lossless": true }`) sets the encoding the style prefers in `renderImage`.
432
- - **Overrides by id** work exactly as for the built-ins (`grain: false`, `mesh: { blur: 30 }`), and so do extra `layers`.
433
- - **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}")`.
434
- - The schema is exported as `@arraypress/covers/recipe.schema.json`, and the built-ins as `@arraypress/covers/recipes/<name>.json` to copy and edit.
435
-
436
- All sixteen 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).
437
-
438
- ## Astro
439
-
440
- ```ts
441
- // src/pages/covers/[slug].webp.ts
442
- import { getCollection } from 'astro:content';
443
- import { coverEndpoint } from '@arraypress/covers/astro';
444
-
445
- export const { getStaticPaths, GET } = coverEndpoint({
446
- getEntries: () => getCollection('posts'),
447
- cover: (post) => ({ recipe: 'gridTitle', seed: post.id, title: post.data.title, aspect: '16:9' }),
448
- });
449
- ```
450
-
451
- `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`).
452
-
453
- A theme can keep its cover style in its config as JSON and pass it straight through:
454
-
455
- ```ts
456
- // src/config/site.ts
457
- export const siteConfig = {
458
- covers: {
459
- palette: { accent: '#2563eb', bg: '#fdfdfd', text: '#1d1d1f' },
460
- recipe: {
461
- name: 'journal',
462
- params: { background: 'gradient' },
463
- layers: [
464
- { id: 'bg', type: 'linear', angle: 135, colors: ['@accent', { mix: ['@accent', '@colors.1', 0.5, 'oklch'] }] },
465
- { id: 'grain', type: 'grain', amount: 0.05 },
466
- { id: 'title', type: 'text', when: 'title', content: '{title}', color: '@onAccent', size: { mul: ['@u', 0.07] } },
467
- ],
468
- },
469
- },
470
- };
471
-
472
- // src/pages/covers/[slug].webp.ts
473
- import { getCollection } from 'astro:content';
474
- import { coverEndpoint } from '@arraypress/covers/astro';
475
- import { derivePalette } from '@arraypress/covers';
476
- import { siteConfig } from '../../config/site';
477
-
478
- const palette = derivePalette(siteConfig.covers.palette);
479
- export const { getStaticPaths, GET } = coverEndpoint({
480
- getEntries: () => getCollection('posts'),
481
- cover: (post) => ({ recipe: siteConfig.covers.recipe, seed: post.id, title: post.data.title, palette }),
482
- });
483
- ```
484
-
485
- Or keep it in a file: `recipe: loadRecipe('./src/covers/journal.json')`.
486
-
487
- | Option | Default | |
488
- |---|---|---|
489
- | `getEntries` | — | The entries. |
490
- | `cover` | — | An entry's cover options (may be async). |
491
- | `slug` | `entry.id ?? entry.slug` | |
492
- | `param` | `'slug'` | The route param, matching the file name. |
493
- | `format` | `'webp'` | Match the file's extension (`[slug].png.ts` → `'png'`). |
494
- | `quality` | per format | |
495
- | `cache` | `true` | See below. |
496
-
497
- Notes:
498
-
499
- - Entries with nested ids (`blog/2026/post`) need a rest route: `[...slug].webp.ts`.
500
- - 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).
501
- - `coverUrl(slug, { base, format })` encodes each path segment.
502
-
503
- ## Caching
504
-
505
- `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).
506
-
507
- ## Performance
508
-
509
- Measured on an Apple M3 Max, Node 26, 1200×1200 WebP at the default quality (median of 5):
510
-
511
- | Recipe | `renderSvg` | `renderImage` | Size |
512
- |---|---|---|---|
513
- | aurora | <1 ms | 154 ms | 245 KB |
514
- | gridTitle | <1 ms | 139 ms | 196 KB |
515
- | iconTile | 2 ms | 103 ms | 37 KB |
516
- | bigType | 1 ms | 79 ms | 32 KB |
517
- | stripes | 1 ms | 84 ms | 69 KB |
518
- | duotone | 21 ms | 204 ms | 159 KB |
519
- | mark | 1 ms | 73 ms | 38 KB |
520
- | diagram | <1 ms | 113 ms | 110 KB |
521
- | cutout | 1 ms | 71 ms | 37 KB |
522
- | riso | 29 ms | 171 ms | 311 KB |
523
- | dither | 3 ms | 70 ms | 156 KB |
524
- | contour | 11 ms | 120 ms | 117 KB |
525
- | waveform | 1 ms | 68 ms | 24 KB |
526
- | bauhaus | <1 ms | 44 ms | 12 KB |
527
- | issue | 1 ms | 43 ms | 16 KB |
528
- | photo | <1 ms | 124 ms | 147 KB |
529
-
530
- (The gradient fields and riso are the heaviest files: saturated colour or speckle plus grain at WebP 90. See "Grain versus file size" below.)
531
-
532
- 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.
533
-
534
- How it rasterises, and why:
535
-
536
- - **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.
537
- - **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.
538
- - **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.
539
- - **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.
540
-
541
- **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.
542
-
543
- **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:
544
-
545
- | Recipe | PNG | WebP lossless | WebP q90 | WebP q80 |
546
- |---|---|---|---|---|
547
- | dither (3 palettes) | 42–45 KB | **~1 KB**, exact | 153–240 KB, 3–5% of pixels wrong | 125–196 KB, 3.5–5% wrong |
548
- | contour (3 palettes) | 808–1074 KB | 511–661 KB | **145–183 KB**, visually exact | 101–135 KB |
549
-
550
- Contour carries grain, so it stays lossy like the rest. Pass `lossless: true` to `renderImage` for any cover.
551
-
552
- ## Gallery
553
-
554
- ```bash
555
- npm run gallery
556
- ```
48
+ ## Links
557
49
 
558
- 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)
559
53
 
560
54
  ## Licence
561
55
 
562
- 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.