@arraypress/covers 0.0.0-stage → 1.1.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 (245) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/LICENSE +29 -0
  3. package/README.md +846 -2
  4. package/fonts/OFL.txt +93 -0
  5. package/fonts/inter-latin-400.woff +0 -0
  6. package/fonts/inter-latin-500.woff +0 -0
  7. package/fonts/inter-latin-600.woff +0 -0
  8. package/fonts/inter-latin-700.woff +0 -0
  9. package/package.json +124 -4
  10. package/recipes/aurora.json +132 -0
  11. package/recipes/bauhaus.json +113 -0
  12. package/recipes/bigType.json +103 -0
  13. package/recipes/brand.json +1143 -0
  14. package/recipes/contour.json +109 -0
  15. package/recipes/cutout.json +191 -0
  16. package/recipes/diagram.json +92 -0
  17. package/recipes/dither.json +170 -0
  18. package/recipes/duotone.json +75 -0
  19. package/recipes/gridTitle.json +136 -0
  20. package/recipes/iconTile.json +214 -0
  21. package/recipes/issue.json +221 -0
  22. package/recipes/mark.json +170 -0
  23. package/recipes/photo.json +156 -0
  24. package/recipes/riso.json +387 -0
  25. package/recipes/stripes.json +206 -0
  26. package/recipes/waveform.json +154 -0
  27. package/schema/recipe.schema.json +8822 -0
  28. package/src/animate/index.d.ts +19 -0
  29. package/src/animate/index.js +116 -0
  30. package/src/astro.d.ts +31 -0
  31. package/src/astro.js +86 -0
  32. package/src/builtins.js +4 -0
  33. package/src/color.js +246 -0
  34. package/src/diagram.js +248 -0
  35. package/src/effects/ascii.d.ts +7 -0
  36. package/src/effects/ascii.js +93 -0
  37. package/src/effects/buffer.d.ts +60 -0
  38. package/src/effects/buffer.js +472 -0
  39. package/src/effects/detail.d.ts +12 -0
  40. package/src/effects/detail.js +124 -0
  41. package/src/effects/digital.d.ts +14 -0
  42. package/src/effects/digital.js +216 -0
  43. package/src/effects/distort.d.ts +20 -0
  44. package/src/effects/distort.js +314 -0
  45. package/src/effects/dither.d.ts +8 -0
  46. package/src/effects/dither.js +147 -0
  47. package/src/effects/drop-shadow.d.ts +4 -0
  48. package/src/effects/drop-shadow.js +45 -0
  49. package/src/effects/extrude.d.ts +5 -0
  50. package/src/effects/extrude.js +55 -0
  51. package/src/effects/film.d.ts +11 -0
  52. package/src/effects/film.js +204 -0
  53. package/src/effects/glow.d.ts +8 -0
  54. package/src/effects/glow.js +56 -0
  55. package/src/effects/grade.d.ts +30 -0
  56. package/src/effects/grade.js +274 -0
  57. package/src/effects/halftone.d.ts +8 -0
  58. package/src/effects/halftone.js +95 -0
  59. package/src/effects/index.d.ts +125 -0
  60. package/src/effects/index.js +277 -0
  61. package/src/effects/ink.d.ts +12 -0
  62. package/src/effects/ink.js +70 -0
  63. package/src/effects/lens.d.ts +14 -0
  64. package/src/effects/lens.js +277 -0
  65. package/src/effects/light.d.ts +10 -0
  66. package/src/effects/light.js +59 -0
  67. package/src/effects/looks.d.ts +9 -0
  68. package/src/effects/looks.js +74 -0
  69. package/src/effects/mask.d.ts +52 -0
  70. package/src/effects/mask.js +314 -0
  71. package/src/effects/motion.d.ts +6 -0
  72. package/src/effects/motion.js +60 -0
  73. package/src/effects/newsprint.d.ts +6 -0
  74. package/src/effects/newsprint.js +89 -0
  75. package/src/effects/optics.d.ts +10 -0
  76. package/src/effects/optics.js +213 -0
  77. package/src/effects/options.d.ts +6 -0
  78. package/src/effects/options.js +106 -0
  79. package/src/effects/paint-presets.d.ts +10 -0
  80. package/src/effects/paint-presets.js +47 -0
  81. package/src/effects/paint.d.ts +49 -0
  82. package/src/effects/paint.js +66 -0
  83. package/src/effects/pixels.d.ts +24 -0
  84. package/src/effects/pixels.js +138 -0
  85. package/src/effects/polar.d.ts +10 -0
  86. package/src/effects/polar.js +265 -0
  87. package/src/effects/region.d.ts +8 -0
  88. package/src/effects/region.js +136 -0
  89. package/src/effects/round-border.d.ts +5 -0
  90. package/src/effects/round-border.js +62 -0
  91. package/src/effects/screen.d.ts +14 -0
  92. package/src/effects/screen.js +212 -0
  93. package/src/effects/sort.d.ts +8 -0
  94. package/src/effects/sort.js +125 -0
  95. package/src/effects/symmetry.d.ts +6 -0
  96. package/src/effects/symmetry.js +74 -0
  97. package/src/effects/tile.d.ts +7 -0
  98. package/src/effects/tile.js +55 -0
  99. package/src/effects/tone.d.ts +16 -0
  100. package/src/effects/tone.js +117 -0
  101. package/src/effects/transform.d.ts +35 -0
  102. package/src/effects/transform.js +127 -0
  103. package/src/effects/util.d.ts +16 -0
  104. package/src/effects/util.js +85 -0
  105. package/src/extract.js +91 -0
  106. package/src/flatten.js +234 -0
  107. package/src/fonts.d.ts +55 -0
  108. package/src/fonts.js +393 -0
  109. package/src/formats/cut.d.ts +10 -0
  110. package/src/formats/cut.js +77 -0
  111. package/src/formats/index.d.ts +6 -0
  112. package/src/formats/index.js +9 -0
  113. package/src/formats/presets.d.ts +41 -0
  114. package/src/formats/presets.js +89 -0
  115. package/src/formats/print.d.ts +25 -0
  116. package/src/formats/print.js +117 -0
  117. package/src/formats/zones.d.ts +10 -0
  118. package/src/formats/zones.js +64 -0
  119. package/src/geometry.js +187 -0
  120. package/src/grid.js +41 -0
  121. package/src/handmade.js +253 -0
  122. package/src/icons.js +191 -0
  123. package/src/index.d.ts +1083 -0
  124. package/src/index.js +27 -0
  125. package/src/inter-data.js +27 -0
  126. package/src/json-recipe.js +616 -0
  127. package/src/kit/index.d.ts +20 -0
  128. package/src/kit/index.js +58 -0
  129. package/src/layer-options.js +56 -0
  130. package/src/layers/barcode.d.ts +19 -0
  131. package/src/layers/barcode.js +63 -0
  132. package/src/layers/code128.d.ts +4 -0
  133. package/src/layers/code128.js +28 -0
  134. package/src/layers/expr.d.ts +6 -0
  135. package/src/layers/expr.js +121 -0
  136. package/src/layers/geometry.d.ts +6 -0
  137. package/src/layers/geometry.js +238 -0
  138. package/src/layers/index.d.ts +18 -0
  139. package/src/layers/index.js +15 -0
  140. package/src/layers/perforation.d.ts +14 -0
  141. package/src/layers/perforation.js +47 -0
  142. package/src/layers/repeat.d.ts +29 -0
  143. package/src/layers/repeat.js +90 -0
  144. package/src/layers/shape.d.ts +46 -0
  145. package/src/layers/shape.js +115 -0
  146. package/src/layers/soft.d.ts +5 -0
  147. package/src/layers/soft.js +43 -0
  148. package/src/layers/svg.d.ts +23 -0
  149. package/src/layers/svg.js +109 -0
  150. package/src/layers/table.d.ts +25 -0
  151. package/src/layers/table.js +80 -0
  152. package/src/layers/tear.d.ts +21 -0
  153. package/src/layers/tear.js +74 -0
  154. package/src/layers/texture.d.ts +17 -0
  155. package/src/layers/texture.js +106 -0
  156. package/src/layers/util.d.ts +8 -0
  157. package/src/layers/util.js +48 -0
  158. package/src/layers.js +1056 -0
  159. package/src/lint/image-size.d.ts +4 -0
  160. package/src/lint/image-size.js +34 -0
  161. package/src/lint/index.d.ts +16 -0
  162. package/src/lint/index.js +127 -0
  163. package/src/lint/rules.d.ts +5 -0
  164. package/src/lint/rules.js +19 -0
  165. package/src/node.d.ts +2 -0
  166. package/src/node.js +20 -0
  167. package/src/palette.js +185 -0
  168. package/src/platform/bytes.d.ts +22 -0
  169. package/src/platform/bytes.js +103 -0
  170. package/src/platform/compress.d.ts +12 -0
  171. package/src/platform/compress.js +81 -0
  172. package/src/platform/harfbuzz.d.ts +9 -0
  173. package/src/platform/harfbuzz.js +80 -0
  174. package/src/platform/index.d.ts +8 -0
  175. package/src/platform/index.js +13 -0
  176. package/src/platform/load.d.ts +6 -0
  177. package/src/platform/load.js +72 -0
  178. package/src/platform/no-harfbuzz.d.ts +2 -0
  179. package/src/platform/no-harfbuzz.js +11 -0
  180. package/src/platform/node.d.ts +26 -0
  181. package/src/platform/node.js +86 -0
  182. package/src/platform/settings.d.ts +28 -0
  183. package/src/platform/settings.js +51 -0
  184. package/src/png.js +126 -0
  185. package/src/print.js +659 -0
  186. package/src/raster/index.d.ts +24 -0
  187. package/src/raster/index.js +171 -0
  188. package/src/recipes.js +79 -0
  189. package/src/render/backdrop.d.ts +6 -0
  190. package/src/render/backdrop.js +55 -0
  191. package/src/render/content.d.ts +5 -0
  192. package/src/render/content.js +58 -0
  193. package/src/render/context.d.ts +82 -0
  194. package/src/render/grain.d.ts +7 -0
  195. package/src/render/grain.js +113 -0
  196. package/src/render/group.d.ts +23 -0
  197. package/src/render/group.js +67 -0
  198. package/src/render/key.d.ts +5 -0
  199. package/src/render/key.js +91 -0
  200. package/src/render/report.d.ts +5 -0
  201. package/src/render/report.js +19 -0
  202. package/src/render/tiles.d.ts +6 -0
  203. package/src/render/tiles.js +77 -0
  204. package/src/render/use.d.ts +27 -0
  205. package/src/render/use.js +73 -0
  206. package/src/render/wrap.d.ts +14 -0
  207. package/src/render/wrap.js +199 -0
  208. package/src/render.js +553 -0
  209. package/src/rng.js +95 -0
  210. package/src/svg.js +69 -0
  211. package/src/text.d.ts +29 -0
  212. package/src/text.js +237 -0
  213. package/src/type/auto-color.d.ts +9 -0
  214. package/src/type/auto-color.js +44 -0
  215. package/src/type/fallback.d.ts +11 -0
  216. package/src/type/fallback.js +92 -0
  217. package/src/type/glyphs.d.ts +24 -0
  218. package/src/type/glyphs.js +87 -0
  219. package/src/type/index.d.ts +5 -0
  220. package/src/type/index.js +10 -0
  221. package/src/type/kern.d.ts +4 -0
  222. package/src/type/kern.js +144 -0
  223. package/src/type/layout-ref.d.ts +10 -0
  224. package/src/type/layout-ref.js +74 -0
  225. package/src/type/line.d.ts +11 -0
  226. package/src/type/line.js +105 -0
  227. package/src/type/live.d.ts +6 -0
  228. package/src/type/live.js +39 -0
  229. package/src/type/name.d.ts +4 -0
  230. package/src/type/name.js +32 -0
  231. package/src/type/path.d.ts +18 -0
  232. package/src/type/path.js +89 -0
  233. package/src/type/pixel.d.ts +9 -0
  234. package/src/type/pixel.js +115 -0
  235. package/src/type/sfnt.d.ts +20 -0
  236. package/src/type/sfnt.js +258 -0
  237. package/src/type/spans.d.ts +33 -0
  238. package/src/type/spans.js +138 -0
  239. package/src/type/symbols.d.ts +7 -0
  240. package/src/type/symbols.js +89 -0
  241. package/src/type/typeset.d.ts +10 -0
  242. package/src/type/typeset.js +352 -0
  243. package/src/type/woff2.d.ts +6 -0
  244. package/src/type/woff2.js +301 -0
  245. package/src/version.js +4 -0
@@ -0,0 +1,19 @@
1
+ // Keyframes against time.
2
+
3
+ /** A keyframed value. */
4
+ export interface Keyframes<T = unknown> {
5
+ /** `[t, value]` pairs (seconds), or `{ t, value, ease }`. */
6
+ keyframes: ([number, T] | [number, T, Ease] | { t: number; value: T; ease?: Ease })[];
7
+ ease?: Ease;
8
+ /** Repeat over the cover's `loop` (or the last key); `'pingpong'` goes back and forth. */
9
+ loop?: boolean | 'pingpong';
10
+ }
11
+ /** Easing curves. */
12
+ export type Ease = 'linear' | 'in' | 'out' | 'inOut' | 'step';
13
+
14
+ /** Whether a value has keyframes anywhere in it. */
15
+ export function isAnimated(v: unknown): boolean;
16
+ /** A keyframed value at time `t` (seconds). */
17
+ export function valueAt<T>(k: Keyframes<T>, t: number, duration?: number): T;
18
+ /** A value with every keyframed part resolved at `t`. */
19
+ export function animate<T>(v: T, t: number, duration?: number): T;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Animation, the data side: any layer option can be keyframed against `t`
3
+ * (seconds), and `renderSvg(cover, { t })` draws the frame at that time.
4
+ * Static covers have no keyframes and ignore `t`, so nothing changes for
5
+ * them. Encoding frames to video is a later phase (see the README).
6
+ *
7
+ * ```json
8
+ * { "type": "text", "content": "{name}",
9
+ * "opacity": { "keyframes": [[0, 0], [0.6, 1]], "ease": "out" },
10
+ * "transform": { "rotate": { "keyframes": [[0, -4], [2, 4]], "loop": "pingpong" } } }
11
+ * ```
12
+ *
13
+ * Numbers interpolate; colours mix in OKLab; lengths with the same unit
14
+ * (`"4vmin"` → `"9vmin"`) interpolate; anything else holds until the next
15
+ * key. `loop: true` repeats over the cover's `loop` (seconds; or the last key),
16
+ * `'pingpong'` goes back and forth.
17
+ *
18
+ * @module animate
19
+ */
20
+
21
+ import { mixOklab, toHex } from '../color.js';
22
+
23
+ /** @param {unknown} v @returns {v is { keyframes: any[] }} */
24
+ const isKeyed = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && Array.isArray(/** @type {any} */ (v).keyframes);
25
+
26
+ /**
27
+ * Whether a layer has keyframes anywhere in its options.
28
+ *
29
+ * @param {unknown} v
30
+ * @returns {boolean}
31
+ */
32
+ export function isAnimated(v) {
33
+ if (isKeyed(v)) return true;
34
+ if (Array.isArray(v)) return v.some(isAnimated);
35
+ if (v && typeof v === 'object' && !(v instanceof Uint8Array)) return Object.values(v).some(isAnimated);
36
+ return false;
37
+ }
38
+
39
+ /** Easing curves on 0–1. */
40
+ const EASE = /** @type {Record<string, (x: number) => number>} */ ({
41
+ linear: (x) => x,
42
+ in: (x) => x * x * x,
43
+ out: (x) => 1 - (1 - x) ** 3,
44
+ inOut: (x) => (x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2),
45
+ step: () => 0,
46
+ });
47
+
48
+ /**
49
+ * Two values `f` of the way between them.
50
+ *
51
+ * @param {any} a @param {any} b @param {number} f
52
+ * @returns {any}
53
+ */
54
+ function mix(a, b, f) {
55
+ if (typeof a === 'number' && typeof b === 'number') return a + (b - a) * f;
56
+ if (typeof a === 'string' && typeof b === 'string') {
57
+ const la = /^(-?[\d.]+)(vmin|vmax|vw|vh|px|%)$/.exec(a);
58
+ const lb = /^(-?[\d.]+)(vmin|vmax|vw|vh|px|%)$/.exec(b);
59
+ if (la && lb && la[2] === lb[2]) return `${+la[1] + (+lb[1] - +la[1]) * f}${la[2]}`;
60
+ try {
61
+ return mixOklab(toHex(a), toHex(b), f);
62
+ } catch {
63
+ return f < 1 ? a : b;
64
+ }
65
+ }
66
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return a.map((x, i) => mix(x, b[i], f));
67
+ if (a && b && typeof a === 'object' && typeof b === 'object') return Object.fromEntries(Object.keys(a).map((k) => [k, k in b ? mix(a[k], b[k], f) : a[k]]));
68
+ return f < 1 ? a : b;
69
+ }
70
+
71
+ /**
72
+ * A keyframed value at time `t`.
73
+ *
74
+ * @param {{ keyframes: any[], ease?: string, loop?: boolean | 'pingpong' }} k
75
+ * @param {number} t
76
+ * @param {number} [duration]
77
+ * @returns {any}
78
+ */
79
+ export function valueAt(k, t, duration) {
80
+ const keys = k.keyframes.map((x) => (Array.isArray(x) ? { t: x[0], value: x[1], ease: x[2] } : x)).sort((a, b) => a.t - b.t);
81
+ if (!keys.length) return undefined;
82
+ const end = duration ?? keys[keys.length - 1].t;
83
+ let time = t;
84
+ if (k.loop && end > 0) {
85
+ time = ((t % end) + end) % end;
86
+ if (k.loop === 'pingpong' && Math.floor(t / end) % 2 === 1) time = end - time;
87
+ }
88
+ if (time <= keys[0].t) return keys[0].value;
89
+ for (let i = 1; i < keys.length; i++) {
90
+ if (time <= keys[i].t) {
91
+ const a = keys[i - 1];
92
+ const b = keys[i];
93
+ const ease = EASE[b.ease ?? k.ease ?? 'inOut'] ?? EASE.inOut;
94
+ return mix(a.value, b.value, ease((time - a.t) / Math.max(1e-9, b.t - a.t)));
95
+ }
96
+ }
97
+ return keys[keys.length - 1].value;
98
+ }
99
+
100
+ /**
101
+ * A layer with every keyframed option resolved at `t`.
102
+ *
103
+ * @template T
104
+ * @param {T} v
105
+ * @param {number} t - Seconds.
106
+ * @param {number} [duration] - The cover's `loop` length in seconds (loops wrap at it).
107
+ * @returns {T}
108
+ */
109
+ export function animate(v, t, duration) {
110
+ if (isKeyed(v)) return valueAt(v, t, duration);
111
+ if (Array.isArray(v)) return /** @type {any} */ (v.map((x) => animate(x, t, duration)));
112
+ if (v && typeof v === 'object' && !(v instanceof Uint8Array) && isAnimated(v)) {
113
+ return /** @type {any} */ (Object.fromEntries(Object.entries(v).map(([k, x]) => [k, animate(x, t, duration)])));
114
+ }
115
+ return v;
116
+ }
package/src/astro.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { CoverOptions, Cover, MIME } from './index.js';
2
+
3
+ export interface CoverEndpointOptions<T> {
4
+ /** The entries to make covers for, e.g. `() => getCollection('posts')`. */
5
+ getEntries: () => T[] | Promise<T[]>;
6
+ /** An entry's cover. */
7
+ cover: (entry: T) => CoverOptions | Cover | Promise<CoverOptions | Cover>;
8
+ /** An entry's slug. Default `entry.id ?? entry.slug`. */
9
+ slug?: (entry: T) => string;
10
+ /** The route param, matching the file name (`[slug].webp.ts`). Default `'slug'`. */
11
+ param?: string;
12
+ /** Default `'webp'`; match the file name's extension. */
13
+ format?: keyof typeof MIME;
14
+ quality?: number;
15
+ /** Content-hash cache: `true` (default) for `node_modules/.cache/covers`, a directory, or `false`. */
16
+ cache?: boolean | string;
17
+ }
18
+
19
+ export interface CoverRouteContext<T> {
20
+ params: Record<string, string | undefined>;
21
+ props?: { entry?: T };
22
+ }
23
+
24
+ /** `getStaticPaths` and `GET` for `src/pages/covers/[slug].webp.ts`. */
25
+ export function coverEndpoint<T>(options: CoverEndpointOptions<T>): {
26
+ getStaticPaths: () => Promise<{ params: Record<string, string>; props: { entry: T } }[]>;
27
+ GET: (context: CoverRouteContext<T>) => Promise<Response>;
28
+ };
29
+
30
+ /** `'/covers/<slug>.webp'`. */
31
+ export function coverUrl(slug: string, options?: { base?: string; format?: keyof typeof MIME }): string;
package/src/astro.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Astro helpers: a static endpoint that renders one cover per entry.
3
+ *
4
+ * ```ts
5
+ * // src/pages/covers/[slug].webp.ts
6
+ * import { getCollection } from 'astro:content';
7
+ * import { coverEndpoint } from '@arraypress/covers/astro';
8
+ *
9
+ * export const { getStaticPaths, GET } = coverEndpoint({
10
+ * getEntries: () => getCollection('posts'),
11
+ * cover: (post) => ({ recipe: 'gridTitle', seed: post.id, title: post.data.title }),
12
+ * });
13
+ * ```
14
+ *
15
+ * `astro build` writes `dist/covers/<slug>.webp` for every entry; `astro
16
+ * dev` renders on request. Renders are cached by content hash in
17
+ * `node_modules/.cache/covers`, so an unchanged cover is read back instead
18
+ * of drawn again, across builds.
19
+ *
20
+ * No Astro import: the endpoint is plain `getStaticPaths` / `GET`
21
+ * functions, so this module works with any Astro version (and is easy to
22
+ * test).
23
+ *
24
+ * @module @arraypress/covers/astro
25
+ */
26
+
27
+ import { renderImage, MIME } from './index.js';
28
+
29
+ /**
30
+ * Build `getStaticPaths` and `GET` for a cover route.
31
+ *
32
+ * @template T
33
+ * @param {import('./astro.js').CoverEndpointOptions<T>} options
34
+ * @returns {{ getStaticPaths: () => Promise<{ params: Record<string, string>, props: { entry: T } }[]>, GET: (context: { params: Record<string, string | undefined>, props?: { entry?: T } }) => Promise<Response> }}
35
+ *
36
+ * @example
37
+ * export const { getStaticPaths, GET } = coverEndpoint({
38
+ * getEntries: () => getCollection('posts'),
39
+ * cover: (post) => ({ recipe: 'aurora', seed: post.id, title: post.data.title, palette: 'dusk' }),
40
+ * });
41
+ */
42
+ export function coverEndpoint(options) {
43
+ const { getEntries, cover } = options;
44
+ if (typeof getEntries !== 'function' || typeof cover !== 'function') {
45
+ throw new TypeError('coverEndpoint() needs getEntries and cover functions');
46
+ }
47
+ const param = options.param ?? 'slug';
48
+ const slugOf = options.slug ?? ((/** @type {any} */ e) => String(e.id ?? e.slug));
49
+ const format = options.format ?? 'webp';
50
+ const cache = options.cache ?? true;
51
+
52
+ const getStaticPaths = async () => {
53
+ const entries = await getEntries();
54
+ return entries.map((entry) => ({ params: { [param]: slugOf(entry) }, props: { entry } }));
55
+ };
56
+
57
+ /** @param {{ params: Record<string, string | undefined>, props?: { entry?: any } }} context */
58
+ const GET = async ({ params, props }) => {
59
+ let entry = props?.entry;
60
+ // On-demand routes get no props: look the entry up by its slug.
61
+ if (entry === undefined) entry = (await getEntries()).find((e) => slugOf(e) === params[param]);
62
+ if (entry === undefined) return new Response('Not found', { status: 404 });
63
+ const body = await renderImage(await cover(entry), { format, quality: options.quality, cache });
64
+ return new Response(new Uint8Array(body), {
65
+ headers: { 'Content-Type': MIME[format], 'Content-Length': String(body.length) },
66
+ });
67
+ };
68
+
69
+ return { getStaticPaths, GET };
70
+ }
71
+
72
+ /**
73
+ * The URL of an entry's cover, for `<img src>` and `og:image`.
74
+ *
75
+ * @param {string} slug - The entry's slug (the route param).
76
+ * @param {{ base?: string, format?: keyof typeof MIME }} [options] - Route base (default `'/covers'`) and extension (default `'webp'`).
77
+ * @returns {string} e.g. `'/covers/my-post.webp'`.
78
+ *
79
+ * @example
80
+ * coverUrl(post.id); // '/covers/hello-world.webp'
81
+ */
82
+ export function coverUrl(slug, options = {}) {
83
+ const base = (options.base ?? '/covers').replace(/\/+$/, '');
84
+ const path = String(slug).split('/').map(encodeURIComponent).join('/');
85
+ return `${base}/${path}.${options.format ?? 'webp'}`;
86
+ }
@@ -0,0 +1,4 @@
1
+ // Generated by scripts/generate.mjs from recipes/*.json. Do not edit.
2
+
3
+ /** The built-in recipe documents, by name. */
4
+ export const BUILTIN_JSON = {"aurora":{"$schema":"../schema/recipe.schema.json","name":"aurora","description":"Full-bleed sweeps of the palette's colours, sometimes folded or streaked, with grain (the openai.com/news look). A title sets in the corner only when given.","layers":[{"id":"bg","type":"solid"},{"id":"mesh","type":"mesh","count":4,"blur":"11vmax","size":[0.45,0.8],"streak":1.8,"tone":{"if":"title","then":{"if":{"mode":"dark"},"then":{"l":[0.34,0.5],"c":[0.12,0.22]},"else":{"l":[0.66,0.82],"c":[0.13,0.2]}},"else":{"l":[0.55,0.85],"c":[0.12,0.22]}},"fold":{"if":"title","then":false,"else":{"pick":[false,false,true]}},"streaks":{"if":"title","then":0,"else":{"pick":[0,0,0,6]}},"hues":{"pick":[2,2,3]},"hueSpan":60},{"id":"grain","type":"grain","amount":0.09,"vary":{"pick":["edges",null]}},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.066]},"minSize":{"mul":["@u",0.048]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"@text","content":"{label}"}}}]},"bauhaus":{"$schema":"../schema/recipe.schema.json","name":"bauhaus","description":"A Bauhaus grid of quarter circles, half circles, circles, squares and triangles in the palette's colours and the page colour. A shared `series` keeps a family's grid rhythm while each cover's seed fills the cells.","params":{"series":null},"layers":[{"id":"bg","type":"solid"},{"id":"tiles","type":"tiles","series":"{series}","colors":[{"tone":["@accent",{"l":[0.5,0.75],"c":[0.12,0.22]}]},{"tone":["@colors.1",{"l":[0.5,0.75],"c":[0.12,0.22]}]},{"tone":["@colors.2",{"l":[0.5,0.75],"c":[0.12,0.22]}]},"@text"],"ground":"@bg","region":{"if":"title","then":{"x":0,"y":0,"width":1,"height":0.72},"else":{"x":0,"y":0,"width":1,"height":1}}},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.056]},"minSize":{"mul":["@u",0.042]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":2,"label":{"if":"label","then":{"color":"@text","content":"{label}"}}}]},"bigType":{"$schema":"../schema/recipe.schema.json","name":"bigType","description":"The headline is the cover: large, tight, shrinking to fit four lines, with the label in the accent at the top.","vars":{"corner":{"pick":[[1,0],[0,0],[1,1],[0.5,0]]}},"layers":[{"id":"bg","type":"solid"},{"id":"wash","type":"radial","colors":[{"mix":["@bg","@accent",{"if":{"mode":"dark"},"then":0.16,"else":0.12}]},"@bg"],"x":"$corner.0","y":"$corner.1","radius":"110vmax"},{"id":"grain","type":"grain","amount":0.05},{"id":"label","type":"text","when":"label","content":"{label}","anchor":"top-left","size":{"mul":["@u",0.024]},"weight":600,"letterSpacing":0.09,"transform":"uppercase","color":"@accent","maxLines":1,"balance":false},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":600,"size":{"mul":["@u",0.13]},"minSize":{"mul":["@u",0.078]},"lineHeight":0.98,"letterSpacing":-0.045,"maxLines":4}]},"brand":{"$schema":"../schema/recipe.schema.json","name":"brand","description":"A brand on every format: avatars, headers and banners, posts and stories, business cards (front and back), letterhead, compliment slips, tickets, passes and stickers. kit() uses it by default. Options: name, tagline, mark (path markup on a 512 grid), photo, lines (contact or info lines), person and role (cards and passes), ticket (a ticket number), compliments.","pages":["front","back"],"params":{"tagline":""},"layers":[{"id":"bg","type":"solid","color":"@accent","when":{"not":{"any":[{"format":["letterhead","letterhead-us","compliment-slip","badge","lanyard-pass"]},{"all":[{"format":["business-card","business-card-us"]},{"page":"back"}]}]}}},{"id":"bg","type":"solid","color":"@bg","when":{"any":[{"format":["letterhead","letterhead-us","compliment-slip","badge","lanyard-pass"]},{"all":[{"format":["business-card","business-card-us"]},{"page":"back"}]}]}},{"id":"photo","type":"image","src":"{photo}","when":{"all":["photo",{"not":{"format":["avatar","x-avatar","youtube-avatar","sticker","sticker-square","business-card","business-card-us","letterhead","letterhead-us","compliment-slip","badge","lanyard-pass","ticket"]}}]},"effects":[{"kind":"duotone","colors":[{"mix":["@accent","#000000",0.6]},"@accent",{"mix":["@accent","#ffffff",0.65]}]}],"opacity":0.9},{"id":"scrim","type":"linear","angle":180,"colors":["@accent","@accent"],"opacities":[0,0.75],"when":{"all":["photo",{"not":{"format":["avatar","x-avatar","youtube-avatar","sticker","sticker-square","business-card","business-card-us","letterhead","letterhead-us","compliment-slip","badge","lanyard-pass","ticket"]}}]}},{"id":"mark","type":"icon","markup":"{mark}","anchor":"center","size":{"mul":[{"min":["@safeWidth","@safeHeight"]},0.86]},"color":"@onAccent","when":{"all":["mark",{"format":["avatar","x-avatar","youtube-avatar","sticker","sticker-square"]}]}},{"id":"initial","type":"text","content":{"upper":{"slice":["{name}",0,1]}},"anchor":"center","align":"center","size":{"mul":[{"min":["@safeWidth","@safeHeight"]},0.62]},"weight":700,"letterSpacing":0,"color":"@onAccent","when":{"all":[{"not":"mark"},"name",{"format":["avatar","x-avatar","youtube-avatar","sticker","sticker-square"]}]}},{"id":"mark","type":"icon","markup":"{mark}","anchor":"left","size":{"mul":["@safeHeight",0.6]},"color":"@onAccent","when":{"all":["mark",{"all":[{"not":{"format":["avatar","x-avatar","youtube-avatar","sticker","sticker-square","business-card","business-card-us","letterhead","letterhead-us","compliment-slip","badge","lanyard-pass","ticket"]}},{"aspect":"wide"}]}]}},{"id":"name","type":"text","content":"{name}","label":{"content":"{tagline}","color":{"mix":["@onAccent","@accent",0.3]}},"anchor":"left","weight":700,"letterSpacing":-0.03,"offset":{"if":"mark","then":{"x":{"mul":["@safeHeight",0.74]}},"else":{"x":0}},"maxWidth":{"if":"mark","then":{"sub":["@safeWidth",{"mul":["@safeHeight",0.9]}]},"else":{"mul":["@safeWidth",0.9]}},"size":{"min":[{"mul":["@safeHeight",0.3]},{"mul":["@safeWidth",0.09]}]},"minSize":{"mul":["@safeHeight",0.14]},"maxLines":2,"color":"@onAccent","when":{"all":["name",{"all":[{"not":{"format":["avatar","x-avatar","youtube-avatar","sticker","sticker-square","business-card","business-card-us","letterhead","letterhead-us","compliment-slip","badge","lanyard-pass","ticket"]}},{"aspect":"wide"}]}]}},{"id":"mark","type":"icon","markup":"{mark}","anchor":"center","offset":{"y":{"mul":["@safeHeight",-0.1]}},"size":{"mul":[{"min":["@safeWidth","@safeHeight"]},0.42]},"color":"@onAccent","when":{"all":["mark",{"all":[{"not":{"format":["avatar","x-avatar","youtube-avatar","sticker","sticker-square","business-card","business-card-us","letterhead","letterhead-us","compliment-slip","badge","lanyard-pass","ticket"]}},{"not":{"aspect":"wide"}}]}]}},{"id":"name","type":"text","content":"{name}","label":{"content":"{tagline}","color":{"mix":["@onAccent","@accent",0.3]}},"anchor":{"if":"mark","then":"bottom","else":"center"},"align":"center","weight":700,"letterSpacing":-0.03,"size":{"mul":["@safeWidth",0.1]},"minSize":{"mul":["@safeWidth",0.06]},"maxLines":2,"color":"@onAccent","when":{"all":["name",{"all":[{"not":{"format":["avatar","x-avatar","youtube-avatar","sticker","sticker-square","business-card","business-card-us","letterhead","letterhead-us","compliment-slip","badge","lanyard-pass","ticket"]}},{"not":{"aspect":"wide"}}]}]}},{"id":"mark","type":"icon","markup":"{mark}","anchor":"center","offset":{"y":{"mul":["@trimHeight",-0.08]}},"size":{"mul":["@trimHeight",0.4]},"color":"@onAccent","when":{"all":["mark",{"all":[{"format":["business-card","business-card-us"]},{"not":{"page":"back"}}]}]}},{"id":"name","type":"text","content":"{name}","anchor":"bottom","align":"center","weight":700,"letterSpacing":-0.02,"size":{"mul":["@trimHeight",0.1]},"maxLines":1,"color":"@onAccent","when":{"all":["name",{"all":[{"format":["business-card","business-card-us"]},{"not":{"page":"back"}}]}]}},{"id":"person","type":"text","content":"{opt.person}","label":"{opt.role}","anchor":"top-left","weight":700,"letterSpacing":-0.02,"size":{"mul":["@trimHeight",0.095]},"maxLines":1,"color":"@text","when":{"all":["opt.person",{"all":[{"format":["business-card","business-card-us"]},{"page":"back"}]}]}},{"id":"lines","type":"table","rows":"{lines}","x":"@safeX","y":{"mul":["@height",0.6]},"columns":[{"x":"@safeX"}],"size":{"mul":["@trimHeight",0.058]},"color":"@text","when":{"all":["lines",{"all":[{"format":["business-card","business-card-us"]},{"page":"back"}]}]}},{"id":"mark","type":"icon","markup":"{mark}","anchor":"bottom-right","size":{"mul":["@trimHeight",0.22]},"color":"@accent","when":{"all":["mark",{"all":[{"format":["business-card","business-card-us"]},{"page":"back"}]}]}},{"id":"mark","type":"icon","markup":"{mark}","anchor":"top-left","size":{"mul":["@trimWidth",0.085]},"color":"@accent","when":{"all":["mark",{"format":["letterhead","letterhead-us","compliment-slip"]}]}},{"id":"name","type":"text","content":"{name}","label":{"content":"{tagline}","color":"@muted"},"anchor":"top-right","align":"right","weight":700,"letterSpacing":-0.02,"size":{"mul":["@trimWidth",0.03]},"maxLines":1,"color":"@text","when":{"all":["name",{"format":["letterhead","letterhead-us","compliment-slip"]}]}},{"id":"compliments","type":"text","content":"{opt.compliments}","anchor":"left","weight":400,"size":{"mul":["@trimHeight",0.09]},"color":"@text","when":{"all":["opt.compliments",{"format":"compliment-slip"}]}},{"id":"rule","type":"shape","kind":"line","x1":"@safeX","x2":{"add":["@safeX","@safeWidth"]},"y1":{"sub":[{"add":["@safeY","@safeHeight"]},{"mul":["@trimHeight",{"if":{"format":"compliment-slip"},"then":0.16,"else":0.05}]}]},"stroke":"@accent","strokeWidth":{"mul":["@mm",0.3]},"when":{"format":["letterhead","letterhead-us","compliment-slip"]}},{"id":"lines","type":"table","rows":["{lines}"],"y":{"sub":[{"add":["@safeY","@safeHeight"]},{"mul":["@trimHeight",{"if":{"format":"compliment-slip"},"then":0.08,"else":0.025}]}]},"columns":[{"x":"@safeX"},{"x":"37%"},{"x":"68%"}],"size":{"mul":["@mm",2.6]},"color":"@muted","when":{"all":["lines",{"format":["letterhead","letterhead-us","compliment-slip"]}]}},{"id":"band","type":"shape","kind":"rect","x":0,"y":0,"width":"@width","height":{"mul":["@height",0.36]},"fill":"@accent","when":{"format":["badge","lanyard-pass"]}},{"id":"mark","type":"icon","markup":"{mark}","anchor":"top","size":{"mul":["@trimHeight",0.2]},"color":"@onAccent","when":{"all":["mark",{"format":["badge","lanyard-pass"]}]}},{"id":"name","type":"text","content":"{opt.person}","label":"{opt.role}","anchor":"center","align":"center","offset":{"y":{"mul":["@height",0.1]}},"weight":700,"letterSpacing":-0.03,"size":{"mul":["@trimWidth",0.12]},"maxLines":2,"color":"@text","when":{"all":["opt.person",{"format":["badge","lanyard-pass"]}]}},{"id":"footer","type":"text","content":"{name}","anchor":"bottom","align":"center","weight":600,"size":{"mul":["@trimWidth",0.055]},"letterSpacing":0.08,"transform":"uppercase","color":"@accent","when":{"all":["name",{"format":["badge","lanyard-pass"]}]}},{"id":"name","type":"text","content":"{name}","label":{"content":"{tagline}","color":{"mix":["@onAccent","@accent",0.3]}},"anchor":"left","weight":700,"letterSpacing":-0.03,"size":{"mul":["@trimHeight",0.24]},"maxWidth":{"mul":["@trimWidth",0.6]},"maxLines":2,"color":"@onAccent","when":{"all":["name",{"format":["ticket"]}]}},{"id":"perforation","type":"perforation","x":"74%","y1":"@bleed","y2":{"sub":["@height","@bleed"]},"color":"@onAccent","size":{"mul":["@mm",0.5]},"gap":{"mul":["@mm",2.2]},"when":{"format":["ticket"]}},{"id":"barcode","type":"barcode","value":{"upper":"{opt.ticket}"},"x":"79%","y":"28%","width":"16%","height":"34%","text":true,"color":"@onAccent","when":{"all":["opt.ticket",{"format":["ticket"]}]}},{"id":"lines","type":"table","rows":["{lines.0}"],"y":{"mul":["@height",0.82]},"columns":[{"x":{"add":["@safeX",0]}}],"size":{"mul":["@trimHeight",0.065]},"color":"@onAccent","when":{"all":["lines",{"format":["ticket"]}]}}]},"contour":{"$schema":"../schema/recipe.schema.json","name":"contour","description":"Topographic isolines of seeded noise on a flat field: thin even lines, an index contour every fifth line, one line in the accent.","params":{"levels":null},"vars":{"field":{"mix":["@bg","@accent",{"if":{"mode":"dark"},"then":0.08,"else":0.05}]}},"layers":[{"id":"bg","type":"solid","color":"$field"},{"id":"contour","type":"contour","levels":{"if":"levels","then":"{levels}","else":{"pick":[14,18,22,26,30]}},"color":"@text","opacity":0.42,"index":5,"scale":{"range":[1.8,3.2]}},{"id":"grain","type":"grain","amount":0.035},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.062]},"minSize":{"mul":["@u",0.046]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"@text","content":"{label}"}},"background":{"color":"$field","radius":0,"padding":[{"mul":["@u",0.03]},{"mul":["@u",0.035]}]}}]},"cutout":{"$schema":"../schema/recipe.schema.json","name":"cutout","description":"A flat matte field (or a hand-cut panel on the page colour) with cut-paper shapes and a hand-drawn icon or node graph.","params":{"panel":false,"shapes":null,"graph":false,"stroke":null},"vars":{"field":{"tone":["@accent",{"l":[0.52,0.64],"c":[0.05,0.11]}]},"count":{"if":{"shapes":null},"then":{"if":"panel","then":0,"else":3},"else":"{shapes}"},"ink":{"if":{"stroke":"light"},"then":"#faf9f5","else":{"if":{"stroke":"dark"},"then":"#141413","else":{"if":"panel","then":"#faf9f5","else":{"if":"graph","then":"#141413","else":{"if":{"shapes":0},"then":"#faf9f5","else":"#141413"}}}}},"type":{"readable":[{"if":"panel","then":"@bg","else":"$field"},4.5,"white"]}},"layers":[{"id":"bg","type":"solid","color":{"if":"panel","then":"@bg","else":"$field"}},{"id":"panel","type":"paper","when":"panel","items":[{"kind":"rect","x":0.5,"y":0.5,"width":{"mul":["@width",0.64]},"height":{"mul":["@height",0.6]}}],"region":{"x":0,"y":0,"width":1,"height":1},"color":"$field","wobble":0.3},{"id":"shapes","type":"paper","count":"$count","region":{"x":0.24,"y":0.22,"width":0.52,"height":0.56}},{"id":"icon","type":"scribble","when":{"not":"graph"},"icon":"{icon}","color":"$ink","size":{"if":"panel","then":"34vmin","else":"44vmin"},"stroke":"1.6vmin"},{"id":"graph","type":"scribble","when":"graph","graph":true,"color":"$ink","size":"52vmin","stroke":"1.7vmin"},{"id":"grain","type":"grain","amount":0.035},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.066]},"minSize":{"mul":["@u",0.048]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"$type","content":"{label}"}},"color":"$type"}]},"diagram":{"$schema":"../schema/recipe.schema.json","name":"diagram","description":"White line-art over the gradient field: one icon, or nodes in outlined tiles joined by arrows, with a dotted grid that breaks around them (the engineering-blog look).","params":{"nodes":null,"layout":null,"arrows":null},"layers":[{"id":"bg","type":"solid"},{"id":"mesh","type":"mesh","count":4,"blur":"11vmax","size":[0.45,0.8],"streak":1.8,"tone":{"l":[0.4,0.6],"c":[0.14,0.24]},"fold":{"pick":[false,false,{"sheen":0}]},"hueSpan":60},{"id":"diagram","type":"diagram","icon":"{icon}","nodes":"{nodes}","layout":"{layout}","arrows":"{arrows}","grid":true},{"id":"grain","type":"grain","amount":0.07},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.066]},"minSize":{"mul":["@u",0.048]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"#ffffff","content":"{label}"}},"color":"#ffffff"}]},"dither":{"$schema":"../schema/recipe.schema.json","name":"dither","description":"A soft field (noise, a gradient, or a lit sphere / arch / circle) rendered 1-bit at a visible pixel size: Bayer 4×4 / 8×8 or blue-ish noise, in two colours or three.","params":{"matrix":"bayer4","cell":"0.9vmin","source":null,"shape":null,"three":false},"vars":{"ink":{"tone":["@accent",{"if":{"mode":"dark"},"then":{"l":[0.62,0.85],"c":[0.1,0.22]},"else":{"l":[0.3,0.55],"c":[0.1,0.24]}}]},"colors":{"if":"three","then":["@bg",{"tone":["@colors.1",{"l":[0.55,0.75],"c":[0.1,0.2]}]},"$ink"],"else":["@bg","$ink"]}},"output":{"lossless":true},"layers":[{"id":"dither","type":"dither","colors":"$colors","source":{"if":"source","then":"{source}","else":{"pick":["field","linear","radial"]}},"shape":{"if":"shape","then":"{shape}","else":{"pick":["sphere","none","circle","arch","sphere"]}},"matrix":"{matrix}","cell":"{cell}","center":{"x":{"range":[0.4,0.6]},"y":{"range":[0.38,0.55]}},"size":"28vmin"},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.062]},"minSize":{"mul":["@u",0.046]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"@text","content":"{label}"}},"color":"@text","background":{"color":"@bg","radius":0,"padding":[{"mul":["@u",0.03]},{"mul":["@u",0.035]}]}}]},"duotone":{"$schema":"../schema/recipe.schema.json","name":"duotone","description":"A photo mapped to the palette's shadow and highlight colours, with grain, and a title over a scrim when given.","requires":["image"],"vars":{"shadow":{"if":{"mode":"dark"},"then":"@bg","else":"@text"}},"layers":[{"id":"image","type":"image","src":"{image}","duotone":true},{"id":"scrim","type":"linear","when":"title","angle":180,"colors":["$shadow","$shadow"],"opacities":[0,0.62]},{"id":"grain","type":"grain","amount":0.07},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.066]},"minSize":{"mul":["@u",0.048]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"#e9e9e9","content":"{label}"}},"color":"#ffffff"}]},"gridTitle":{"$schema":"../schema/recipe.schema.json","name":"gridTitle","description":"The aurora field under a dotted grid (four cells each way), grain, and an optional headline lower left.","layers":[{"id":"bg","type":"solid"},{"id":"mesh","type":"mesh","count":4,"blur":"11vmax","size":[0.45,0.8],"streak":1.8,"tone":{"if":"title","then":{"if":{"mode":"dark"},"then":{"l":[0.34,0.5],"c":[0.12,0.22]},"else":{"l":[0.66,0.82],"c":[0.13,0.2]}},"else":{"l":[0.55,0.85],"c":[0.12,0.22]}},"fold":{"if":"title","then":false,"else":{"pick":[false,false,true]}},"streaks":{"if":"title","then":0,"else":{"pick":[0,0,0,6]}},"hues":{"pick":[2,2,3]},"hueSpan":60},{"id":"grid","type":"pattern","kind":"grid","divisions":4,"stroke":"0.3vmin","dash":"dotted","color":"#ffffff","opacity":0.6},{"id":"grain","type":"grain","amount":0.08},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.066]},"minSize":{"mul":["@u",0.048]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"@text","content":"{label}"}}}]},"iconTile":{"$schema":"../schema/recipe.schema.json","name":"iconTile","description":"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 upper left.","vars":{"tint":{"mix":["@bg","@accent",{"if":{"mode":"dark"},"then":0.1,"else":0.07}]},"glow":{"if":"title","then":{"x":{"add":[0.22,{"range":[-0.12,0.12]}]},"y":{"add":[0.26,{"range":[-0.12,0.12]}]}},"else":{"x":{"add":[0.5,{"range":[-0.12,0.12]}]},"y":{"add":[0.5,{"range":[-0.12,0.12]}]}}}},"layers":[{"id":"bg","type":"solid","color":"$tint"},{"id":"glow","type":"radial","colors":[{"mix":["$tint","@accent",{"if":{"mode":"dark"},"then":0.32,"else":0.26}]},"$tint"],"x":"$glow.x","y":"$glow.y","radius":"52vmax"},{"id":"rings","type":"pattern","kind":"rings","size":{"if":"title","then":"6vmin","else":"7vmin"},"center":"$glow","opacity":{"if":{"mode":"dark"},"then":0.14,"else":0.16},"fade":"radial","color":{"if":{"mode":"dark"},"then":"@text","else":"@accent"}},{"id":"icon","type":"icon","name":"{icon}","path":{"if":{"not":"icon"},"then":"M12 2c.4 4.9 2.1 7.6 7 8.6l3 .4-3 .4c-4.9 1-6.6 3.7-7 8.6-.4-4.9-2.1-7.6-7-8.6L2 11l3-.4c4.9-1 6.6-3.7 7-8.6Z"},"viewBox":{"if":{"not":"icon"},"then":[0,0,24,24]},"size":{"mul":["@u",{"if":"title","then":0.075,"else":0.12}]},"anchor":{"if":"title","then":"top-left","else":"center"},"tile":{"color":{"if":{"mode":"dark"},"then":{"mix":["$tint","@text",0.09]},"else":{"mix":["$tint","#ffffff",0.85]}}}},{"id":"grain","type":"grain","amount":0.05},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.066]},"minSize":{"mul":["@u",0.048]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":"{label}"}]},"issue":{"$schema":"../schema/recipe.schema.json","name":"issue","description":"Type-led: a huge issue number in the accent, sized to the canvas and cropped at the bottom only, an optional No., a masthead line with the date, a hairline.","params":{"number":null,"masthead":null,"date":null,"prefix":true},"vars":{"number":{"if":"number","then":"{number}","else":{"int":[1,64]}},"numeral":{"readable":["@bg",3,"@accent"]},"size":{"min":[{"mul":["@height",1.04]},{"div":[{"mul":["@width",1.62]},{"length":"$number"}]}]},"bleed":{"add":[1,{"div":[{"mul":["$size",0.1]},"@height"]}]}},"layers":[{"id":"bg","type":"solid"},{"id":"masthead","type":"text","when":"masthead","content":"{masthead}","anchor":"top-left","size":{"mul":["@u",0.024]},"weight":600,"letterSpacing":0.08,"transform":"uppercase","maxLines":1,"balance":false,"color":"@text"},{"id":"date","type":"text","when":"date","content":"{date}","anchor":"top-right","size":{"mul":["@u",0.024]},"weight":500,"letterSpacing":0.04,"maxLines":1,"balance":false,"color":"@text"},{"id":"rule","type":"rule","y":{"if":{"any":["masthead","date"]},"then":0.13,"else":0.1}},{"id":"no","type":"text","when":{"not":{"prefix":false}},"content":"No.","anchor":"left","position":{"x":0.07,"y":{"if":"title","then":0.31,"else":0.27}},"size":{"mul":["@u",0.05]},"weight":500,"letterSpacing":-0.02,"color":"$numeral","maxLines":1,"balance":false},{"id":"number","type":"text","content":"$number","anchor":"bottom-left","position":{"x":0.055,"y":"$bleed"},"size":"$size","weight":600,"letterSpacing":-0.06,"lineHeight":1,"maxLines":1,"maxWidth":100000,"balance":false,"color":"$numeral"},{"id":"title","type":"text","when":"title","content":"{title}","anchor":{"if":{"aspect":"wide"},"then":"top-right","else":"top-left"},"weight":500,"size":{"mul":["@u",0.05]},"minSize":{"mul":["@u",0.04]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":2,"label":{"if":"label","then":{"color":"@text","content":"{label}"}},"offset":{"y":"9vh"},"maxWidth":{"if":{"aspect":"wide"},"then":"40vw","else":"52vw"},"align":"left"}]},"mark":{"$schema":"../schema/recipe.schema.json","name":"mark","description":"One icon, centred, on the accent or an accent gradient: white whenever white clears 3:1 (WCAG 1.4.11, non-text) against everything under it, else near-black.","params":{"background":"solid","angle":135,"colors":null,"size":"24vmin","iconColor":"auto"},"vars":{"gradient":{"if":"colors","then":"{colors}","else":["@accent",{"mix":["@accent","@colors.1",0.5,"oklch"]}]},"ink":{"if":{"iconColor":"auto"},"then":{"if":{"background":"gradient"},"then":{"readable":[{"gradientUnder":["$gradient","{angle}","{size}"]},3,"white"]},"else":{"readable":["@accent",3,"white"]}},"else":{"if":{"iconColor":"white"},"then":"#ffffff","else":{"if":{"iconColor":"black"},"then":"#111111","else":"{iconColor}"}}},"type":{"if":{"background":"gradient"},"then":{"readable":["$gradient",4.5,"$ink"]},"else":{"readable":["@accent",4.5,"$ink"]}}},"layers":[{"id":"bg","type":"solid","when":{"background":"solid"},"color":"@accent"},{"id":"bg","type":"linear","when":{"background":"gradient"},"angle":"{angle}","colors":"$gradient"},{"id":"icon","type":"icon","name":"{icon}","path":{"if":{"not":"icon"},"then":"M12 2c.4 4.9 2.1 7.6 7 8.6l3 .4-3 .4c-4.9 1-6.6 3.7-7 8.6-.4-4.9-2.1-7.6-7-8.6L2 11l3-.4c4.9-1 6.6-3.7 7-8.6Z"},"viewBox":{"if":{"not":"icon"},"then":[0,0,24,24]},"size":"{size}","color":"$ink"},{"id":"grain","type":"grain","amount":0.03},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.066]},"minSize":{"mul":["@u",0.048]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"$type","content":"{label}"}},"color":"$type"}]},"photo":{"$schema":"../schema/recipe.schema.json","name":"photo","description":"A palette taken from a photo, laid out as full-bleed sweeps like aurora (the photo itself not shown), or `show: 'blur'` for the photo heavily blurred.","params":{"show":null},"requires":["image"],"vars":{"src":{"if":"image","then":"{image}","else":null}},"layers":[{"id":"bg","type":"solid"},{"id":"mesh","type":"mesh","when":{"not":{"show":"blur"}},"from":"$src","count":4,"blur":"11vmax","size":[0.45,0.8],"streak":1.8,"hues":3,"tone":{"if":"title","then":{"if":{"mode":"dark"},"then":{"l":[0.34,0.5],"c":[0.08,0.2]},"else":{"l":[0.66,0.82],"c":[0.08,0.18]}},"else":{"l":[0.5,0.85],"c":[0.08,0.2]}},"fold":{"if":"title","then":false,"else":{"pick":[false,false,true]}}},{"id":"photo","type":"image","when":{"show":"blur"},"src":"$src","blur":"7vmax"},{"id":"scrim","type":"solid","when":{"all":[{"show":"blur"},"title"]},"color":{"if":{"mode":"dark"},"then":"#000000","else":"#ffffff"},"opacity":0.4},{"id":"grain","type":"grain","amount":0.08},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.062]},"minSize":{"mul":["@u",0.046]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"@text","content":"{label}"}},"color":"@text"}]},"riso":{"$schema":"../schema/recipe.schema.json","name":"riso","description":"Two (or three) flat risograph inks overprinted on paper: big simple forms, slight misregistration, speckled or halftone texture, uneven density. Inks from the palette, or classic riso inks.","params":{"inks":null,"texture":"grain","third":false},"vars":{"ground":{"if":{"mode":"dark"},"then":"@bg","else":"#f6f1e7"},"blend":{"if":{"mode":"dark"},"then":"screen","else":"multiply"},"inks":{"if":"inks","then":"{inks}","else":[{"tone":["@accent",{"l":[0.5,0.72],"c":[0.14,0.26]}]},{"tone":["@colors.1",{"l":[0.5,0.72],"c":[0.14,0.26]}]},{"tone":["@colors.2",{"l":[0.5,0.72],"c":[0.14,0.26]}]}]},"comp":{"if":"title","then":{"pick":[[[{"kind":"circle","x":{"range":[0.4,0.6]},"y":0.32,"r":"24vmin"}],[{"kind":"band","y":0.42,"height":"16vmin","rotate":{"range":[-8,8]}}]],[[{"kind":"arch","x":{"range":[0.34,0.42]},"y":0.34,"width":"34vmin","height":"44vmin"}],[{"kind":"circle","x":{"range":[0.58,0.66]},"y":0.3,"r":"16vmin"}]],[[{"kind":"half","x":0.5,"y":0.5,"r":"32vmin"}],[{"kind":"circle","x":0.5,"y":0.3,"r":"14vmin"}]]]},"else":{"pick":[[[{"kind":"circle","x":{"range":[0.36,0.5]},"y":{"range":[0.38,0.5]},"r":"34vmin"}],[{"kind":"band","y":{"range":[0.56,0.68]},"height":"26vmin","rotate":{"range":[-14,14]}}]],[[{"kind":"arch","x":{"range":[0.32,0.42]},"y":0.54,"width":"46vmin","height":"66vmin"}],[{"kind":"circle","x":{"range":[0.6,0.68]},"y":{"range":[0.34,0.44]},"r":"22vmin"}]],[[{"kind":"half","x":0.5,"y":{"range":[0.6,0.7]},"r":"42vmin"}],[{"kind":"circle","x":{"range":[0.42,0.58]},"y":{"range":[0.34,0.42]},"r":"19vmin"}]],[[{"kind":"ring","x":{"range":[0.4,0.48]},"y":0.48,"r":"30vmin","thickness":"12vmin"}],[{"kind":"circle","x":{"range":[0.56,0.64]},"y":{"range":[0.5,0.58]},"r":"24vmin"}]]]}}},"layers":[{"id":"bg","type":"solid","color":"$ground"},{"id":"inkA","type":"ink","color":"$inks.0","shapes":"$comp.0","texture":"{texture}","blend":"$blend"},{"id":"inkB","type":"ink","color":"$inks.1","shapes":"$comp.1","texture":"{texture}","blend":"$blend"},{"id":"inkC","type":"ink","when":"third","color":"$inks.2","shapes":[{"kind":"circle","x":{"range":[0.2,0.8]},"y":{"range":[0.2,0.4]},"r":"7vmin"}],"texture":"{texture}","blend":"$blend"},{"id":"grain","type":"grain","amount":0.05},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"bottom-left","weight":500,"size":{"mul":["@u",0.062]},"minSize":{"mul":["@u",0.046]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"@text","content":"{label}"}},"color":"@text"}]},"stripes":{"$schema":"../schema/recipe.schema.json","name":"stripes","description":"A tonal band of tapering stripes down one side, a disc riding its edge (with an optional icon), and the title on the open side (the claude.com/resources look).","params":{"side":"right"},"vars":{"spotY":{"add":[{"if":"title","then":0.36,"else":0.5},{"range":[-0.06,0.06]}]},"steps":{"int":[5,8]},"taper":{"range":[0.72,0.86]},"ramp":{"ramp":["@accent","@bg","$steps",0.88]},"weights":{"taper":["$taper","$steps"]},"edge":{"if":{"side":"right"},"then":{"sub":[1,0.46]},"else":0.46},"spot":{"mul":[{"min":["@width","@height"]},0.34]}},"layers":[{"id":"bg","type":"solid"},{"id":"stripes","type":"stripes","direction":"vertical","region":{"if":{"side":"right"},"then":{"x":"$edge","y":0,"width":0.46,"height":1},"else":{"x":0,"y":0,"width":0.46,"height":1}},"widths":{"if":{"side":"right"},"then":{"reverse":"$weights"},"else":"$weights"},"colors":{"if":{"side":"right"},"then":{"reverse":"$ramp"},"else":"$ramp"}},{"id":"spot","type":"shapes","items":[{"kind":"circle","x":"$edge","y":"$spotY","size":"$spot","color":"@text"}]},{"id":"icon","type":"icon","when":"icon","name":"{icon}","size":{"mul":["$spot",0.4]},"position":{"x":"$edge","y":"$spotY"},"color":"@bg"},{"id":"grain","type":"grain","amount":0.06},{"id":"title","type":"text","when":"title","content":"{title}","anchor":{"if":{"side":"right"},"then":"bottom-left","else":"bottom-right"},"weight":500,"size":{"mul":["@u",0.058]},"minSize":{"mul":["@u",0.044]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":4,"label":"{label}","maxWidth":{"sub":[{"mul":["@width",{"sub":[1,0.46]}]},{"mul":["@u",0.12]}]}}]},"waveform":{"$schema":"../schema/recipe.schema.json","name":"waveform","description":"An episode's waveform (peaks you pass, or audio decoded at build) as bars, mirrored bars, a line or dots, on a flat or toned field, with an optional duration and label. Without peaks or audio it draws seeded demo peaks.","params":{"peaks":null,"audio":null,"duration":null,"style":null,"field":"flat"},"vars":{"field":{"if":{"field":"toned"},"then":{"tone":["@accent",{"if":{"mode":"dark"},"then":{"l":[0.3,0.4],"c":[0.06,0.12]},"else":{"l":[0.88,0.94],"c":[0.03,0.06]}}]},"else":"@bg"},"wave":{"readable":["$field",3,"@accent"]},"type":{"readable":["$field",4.5,"@text"]}},"layers":[{"id":"bg","type":"solid","color":"$field"},{"id":"wave","type":"waveform","peaks":"{peaks}","audio":"{audio}","style":{"if":"style","then":"{style}","else":{"pick":["mirrored","bars","line","dots","mirrored"]}},"color":"$wave","duration":"{duration}","caption":{"if":{"any":["duration","audio"]},"then":true,"else":null},"captionColor":"$type","region":{"if":"title","then":{"x":0.07,"y":0.42,"width":0.86,"height":0.3},"else":{"x":0.07,"y":0.3,"width":0.86,"height":0.4}}},{"id":"grain","type":"grain","amount":0.04},{"id":"title","type":"text","when":"title","content":"{title}","anchor":"top-left","weight":500,"size":{"mul":["@u",0.062]},"minSize":{"mul":["@u",0.046]},"lineHeight":1.08,"letterSpacing":-0.025,"maxLines":3,"label":{"if":"label","then":{"color":"$type","content":"{label}"}},"color":"$type"}]}};
package/src/color.js ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Colour parsing and the few colour-space moves palettes need.
3
+ *
4
+ * Everything inside the library is a lowercase `#rrggbb` string. Input can
5
+ * be hex (3, 4, 6 or 8 digits), `rgb()`/`rgba()` or `hsl()`/`hsla()`; alpha
6
+ * is dropped, since a cover's layers carry their own `opacity`.
7
+ *
8
+ * Mixing and contrast come from `@arraypress/color-utils`; this module adds
9
+ * OKLCH, so derived palettes can rotate hue and set lightness perceptually
10
+ * instead of in sRGB (where "the same lightness" means very different
11
+ * things for yellow and blue).
12
+ *
13
+ * @module color
14
+ */
15
+
16
+ import { hexToRgb, rgbToHex, contrastRatio } from '@arraypress/color-utils';
17
+
18
+ /**
19
+ * Parse a CSS colour to `#rrggbb`.
20
+ *
21
+ * @param {string} input - `#abc`, `#aabbcc`, `#aabbccdd`, `rgb(…)`, `rgba(…)`, `hsl(…)` or `hsla(…)`.
22
+ * @returns {string} Lowercase `#rrggbb`.
23
+ * @throws {TypeError} When the string isn't a colour this function understands.
24
+ *
25
+ * @example
26
+ * toHex('rgb(37 99 235)'); // '#2563eb'
27
+ * toHex('#FA0'); // '#ffaa00'
28
+ * toHex('hsl(220 80% 50%)'); // '#1a5ee6'
29
+ */
30
+ export function toHex(input) {
31
+ if (typeof input !== 'string') throw new TypeError(`Expected a colour string, got ${typeof input}`);
32
+ const s = input.trim().toLowerCase();
33
+
34
+ const hex = s.match(/^#?([0-9a-f]{3,8})$/);
35
+ if (hex && [3, 4, 6, 8].includes(hex[1].length)) {
36
+ let h = hex[1];
37
+ if (h.length <= 4) h = h.split('').map((c) => c + c).join('');
38
+ return '#' + h.slice(0, 6);
39
+ }
40
+
41
+ const fn = s.match(/^(rgba?|hsla?)\(([^)]*)\)$/);
42
+ if (fn) {
43
+ const parts = fn[2].split(/[\s,/]+/).filter(Boolean);
44
+ if (parts.length >= 3) {
45
+ if (fn[1].startsWith('rgb')) {
46
+ const [r, g, b] = parts.slice(0, 3).map((p) => (p.endsWith('%') ? parseFloat(p) * 2.55 : parseFloat(p)));
47
+ if ([r, g, b].every(Number.isFinite)) return rgbToHex(r, g, b);
48
+ } else {
49
+ const h = parseFloat(parts[0]);
50
+ const sat = parseFloat(parts[1]) / 100;
51
+ const l = parseFloat(parts[2]) / 100;
52
+ if ([h, sat, l].every(Number.isFinite)) return hslToHex(h, sat, l);
53
+ }
54
+ }
55
+ }
56
+ throw new TypeError(`Unrecognised colour: "${input}"`);
57
+ }
58
+
59
+ /**
60
+ * HSL to `#rrggbb`.
61
+ *
62
+ * @param {number} h - Hue in degrees.
63
+ * @param {number} s - Saturation, 0–1.
64
+ * @param {number} l - Lightness, 0–1.
65
+ * @returns {string} `#rrggbb`.
66
+ */
67
+ function hslToHex(h, s, l) {
68
+ const k = (n) => (n + h / 30) % 12;
69
+ const a = s * Math.min(l, 1 - l);
70
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
71
+ return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255);
72
+ }
73
+
74
+ /** @param {number} c - sRGB channel 0–1. @returns {number} Linear channel. */
75
+ const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
76
+ /** @param {number} c - Linear channel. @returns {number} sRGB channel 0–1. */
77
+ const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
78
+
79
+ /**
80
+ * `#rrggbb` to OKLCH.
81
+ *
82
+ * @param {string} color - Any colour {@link toHex} accepts.
83
+ * @returns {{ l: number, c: number, h: number }} Lightness 0–1, chroma (~0–0.37), hue in degrees.
84
+ *
85
+ * @example
86
+ * toOklch('#2563eb'); // { l: 0.546, c: 0.215, h: 262.9 }
87
+ */
88
+ export function toOklch(color) {
89
+ const { r, g, b } = hexToRgb(toHex(color));
90
+ const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
91
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
92
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
93
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
94
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
95
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
96
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
97
+ const h = (Math.atan2(B, A) * 180) / Math.PI;
98
+ return { l: L, c: Math.hypot(A, B), h: h < 0 ? h + 360 : h };
99
+ }
100
+
101
+ /**
102
+ * OKLCH to `#rrggbb`. Out-of-gamut colours are brought in by reducing
103
+ * chroma (keeping lightness and hue), not by clipping channels, so a
104
+ * derived colour keeps the hue it was asked for.
105
+ *
106
+ * @param {{ l: number, c: number, h: number }} lch - Lightness 0–1, chroma, hue in degrees.
107
+ * @returns {string} `#rrggbb`.
108
+ *
109
+ * @example
110
+ * fromOklch({ l: 0.7, c: 0.12, h: 30 }); // a soft coral
111
+ */
112
+ export function fromOklch({ l, c, h }) {
113
+ const L = Math.max(0, Math.min(1, l));
114
+ let chroma = Math.max(0, c);
115
+ for (let i = 0; i < 24; i++) {
116
+ const rgb = oklabToRgb(L, chroma * Math.cos((h * Math.PI) / 180), chroma * Math.sin((h * Math.PI) / 180));
117
+ if (rgb.every((v) => v >= -0.0005 && v <= 1.0005) || chroma < 0.001) {
118
+ return toRgbHex(rgb);
119
+ }
120
+ chroma *= 0.9;
121
+ }
122
+ return toRgbHex(oklabToRgb(L, 0, 0));
123
+ }
124
+
125
+ /**
126
+ * Clamp 0–1 sRGB channels and write them as `#rrggbb`.
127
+ *
128
+ * @param {[number, number, number]} rgb
129
+ * @returns {string}
130
+ */
131
+ function toRgbHex([r, g, b]) {
132
+ const c = (/** @type {number} */ v) => Math.max(0, Math.min(1, v)) * 255;
133
+ return rgbToHex(c(r), c(g), c(b));
134
+ }
135
+
136
+ /**
137
+ * OKLab to gamma-encoded sRGB channels (0–1, possibly out of range).
138
+ *
139
+ * @param {number} L @param {number} A @param {number} B
140
+ * @returns {[number, number, number]} sRGB channels.
141
+ */
142
+ function oklabToRgb(L, A, B) {
143
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
144
+ const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
145
+ const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
146
+ return [
147
+ toGamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
148
+ toGamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
149
+ toGamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
150
+ ];
151
+ }
152
+
153
+ /**
154
+ * Mix two colours in OKLab (perceptually even, no muddy midpoints).
155
+ *
156
+ * @param {string} a - First colour.
157
+ * @param {string} b - Second colour.
158
+ * @param {number} t - 0 gives `a`, 1 gives `b`.
159
+ * @returns {string} `#rrggbb`.
160
+ *
161
+ * @example
162
+ * mixOklab('#ffffff', '#2563eb', 0.1); // a pale blue tint
163
+ */
164
+ export function mixOklab(a, b, t) {
165
+ const p = toLab(a);
166
+ const q = toLab(b);
167
+ const rgb = oklabToRgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t);
168
+ return toRgbHex(rgb);
169
+ }
170
+
171
+ /** @param {string} color @returns {[number, number, number]} OKLab. */
172
+ function toLab(color) {
173
+ const { l, c, h } = toOklch(color);
174
+ return [l, c * Math.cos((h * Math.PI) / 180), c * Math.sin((h * Math.PI) / 180)];
175
+ }
176
+
177
+ /**
178
+ * Mix two colours in OKLCH: lightness and chroma linearly, hue the short
179
+ * way round. Unlike OKLab, a mix of two far-apart hues stays vivid instead
180
+ * of passing through grey (orange → blue goes by way of pink, not mud).
181
+ *
182
+ * @param {string} a - First colour.
183
+ * @param {string} b - Second colour.
184
+ * @param {number} t - 0 gives `a`, 1 gives `b`.
185
+ * @returns {string} `#rrggbb`.
186
+ *
187
+ * @example
188
+ * mixOklch('#c9502a', '#a9bdf2', 0.5); // a clear rose, not a muddy mauve
189
+ */
190
+ export function mixOklch(a, b, t) {
191
+ const p = toOklch(a);
192
+ const q = toOklch(b);
193
+ // A near-grey has no meaningful hue: take the other colour's.
194
+ const ph = p.c < 0.02 ? q.h : p.h;
195
+ const qh = q.c < 0.02 ? p.h : q.h;
196
+ const dh = ((qh - ph + 540) % 360) - 180;
197
+ return fromOklch({ l: p.l + (q.l - p.l) * t, c: p.c + (q.c - p.c) * t, h: (ph + dh * t + 360) % 360 });
198
+ }
199
+
200
+ /** Near-black used wherever white doesn't read. */
201
+ export const NEAR_BLACK = '#111111';
202
+
203
+ /**
204
+ * A colour for a mark on one or more backgrounds: `prefer` when it clears
205
+ * `ratio` against every one of them, else whichever of white and
206
+ * near-black has the better worst case.
207
+ *
208
+ * For icons and other non-text marks WCAG 1.4.11 asks 3:1 (text needs
209
+ * 4.5:1), so `onBackground(bg, 3, 'white')` keeps white on mid-tone fills
210
+ * where a text rule would flip to black.
211
+ *
212
+ * @param {string | string[]} backgrounds - The colour(s) under the mark.
213
+ * @param {number} [ratio=3] - Contrast to clear.
214
+ * @param {'white' | 'black' | string} [prefer='white'] - The colour to use when it clears `ratio`.
215
+ * @returns {string} `#rrggbb`.
216
+ *
217
+ * @example
218
+ * onBackground('#e0533d', 3, 'white'); // '#ffffff' (3.6:1)
219
+ * onBackground('#ffe14d', 3, 'white'); // '#111111'
220
+ */
221
+ export function onBackground(backgrounds, ratio = 3, prefer = 'white') {
222
+ const bgs = [].concat(/** @type {any} */ (backgrounds)).map(toHex);
223
+ const want = prefer === 'white' ? '#ffffff' : prefer === 'black' ? NEAR_BLACK : toHex(prefer);
224
+ const worst = (/** @type {string} */ c) => Math.min(...bgs.map((b) => contrastRatio(c, b)));
225
+ if (worst(want) >= ratio) return want;
226
+ return worst('#ffffff') >= worst(NEAR_BLACK) ? '#ffffff' : NEAR_BLACK;
227
+ }
228
+
229
+ /**
230
+ * The colour of an eased multi-stop gradient at `t` (0–1), the way the
231
+ * `linear` and `radial` layers draw it (smoothstep between stops, in OKLab).
232
+ *
233
+ * @param {string[]} colors - Two or more stops, evenly spaced.
234
+ * @param {number} t - Position along the gradient.
235
+ * @param {boolean} [ease=true] - Ease between stops.
236
+ * @returns {string} `#rrggbb`.
237
+ */
238
+ export function gradientAt(colors, t, ease = true) {
239
+ const list = colors.map(toHex);
240
+ if (list.length === 1) return list[0];
241
+ const n = list.length - 1;
242
+ const x = Math.max(0, Math.min(1, t)) * n;
243
+ const i = Math.min(n - 1, Math.floor(x));
244
+ const f = x - i;
245
+ return mixOklab(list[i], list[i + 1], ease ? f * f * (3 - 2 * f) : f);
246
+ }